commit bb6e4b995f9b47f9b8090bc8267fd05ce5f71448 Author: sstent Date: Sun Jan 18 06:21:10 2026 -0800 first commit diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..0148f0c --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,64 @@ +name: Build and Push Docker Image + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + container_sha: ${{ github.sha }} + registry_url: ${{ steps.registry.outputs.url }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set registry URL + id: registry + run: | + if [ "${{ github.server_url }}" = "https://github.com" ]; then + echo "url=ghcr.io" >> $GITHUB_OUTPUT + else + echo "url=${{ github.server_url }}" | sed 's|https://||' >> $GITHUB_OUTPUT + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.url }} + username: ${{ github.actor }} + password: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push multi-arch Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ steps.registry.outputs.url }}/${{ github.repository }}:latest + ${{ steps.registry.outputs.url }}/${{ github.repository }}:${{ github.sha }} + build-args: | + COMMIT_SHA=${{ github.sha }} + cache-from: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache,mode=max + #cache-from: type=gha + #cache-to: type=gha,mode=max + + # --- AUTOMATIC REPOSITORY LINKING --- + # This label adds an OCI annotation that Gitea uses to automatically + # link the package to the repository source code. + labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} diff --git a/.github/workflows/nomad-deploy.yml b/.github/workflows/nomad-deploy.yml new file mode 100644 index 0000000..cbe03e5 --- /dev/null +++ b/.github/workflows/nomad-deploy.yml @@ -0,0 +1,56 @@ +name: Deploy to Nomad + +on: + workflow_run: + workflows: ["Build and Push Docker Image"] # Must match your build workflow name exactly + types: + - completed + workflow_dispatch: # Allows manual triggering for testing + inputs: + container_sha: + description: 'Container SHA to deploy (leave empty for latest commit)' + required: false + type: string + +jobs: + nomad: + runs-on: ubuntu-latest + name: Deploy to Nomad + # Only run if the build workflow succeeded + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + + steps: + # 1. Checkout Code + - name: Checkout Repository + uses: actions/checkout@v4 + + # 2. Install Nomad CLI + - name: Setup Nomad CLI + uses: hashicorp/setup-nomad@main + with: + version: '1.10.0' # Use your desired version or remove for 'latest' + + # 3. Determine container version to deploy + - name: Set Container Version + id: container_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.container_sha }}" ]; then + echo "sha=${{ inputs.container_sha }}" >> $GITHUB_OUTPUT + elif [ "${{ github.event_name }}" = "workflow_run" ]; then + echo "sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT + else + echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT + fi + + # 4. Deploy the Nomad Job + - name: Deploy Nomad Job + id: deploy + env: + # REQUIRED: Set the Nomad server address + NOMAD_ADDR: http://nomad.service.dc1.consul:4646 + run: | + echo "Deploying container version: ${{ steps.container_version.outputs.sha }}" + nomad status + nomad job run \ + -var="container_version=${{ steps.container_version.outputs.sha }}" \ + foodplanner.nomad diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fc28190 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY main.py . + +# Run the application +CMD ["python", "-u", "main.py"] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ef4ecc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + finance-emailer: + build: . + container_name: daily-finance-emailer + restart: unless-stopped + environment: + - GMAIL_USER=${GMAIL_USER} + - GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD} + - RECIPIENT_EMAIL=${RECIPIENT_EMAIL} + - TZ=America/New_York # Adjust to your timezone + env_file: + - .env \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..359e5fb --- /dev/null +++ b/main.py @@ -0,0 +1,203 @@ +import os +import smtplib +import yfinance as yf +import requests +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime +import pandas as pd +import schedule +import time + +import logging +import socket + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +# Set user agent to avoid Yahoo Finance blocking +yf.set_tz_cache_location("/tmp/yfinance_cache") + +def check_network(): + """Verify network connectivity to key services""" + try: + host = "query2.finance.yahoo.com" + ip = socket.gethostbyname(host) + logger.info(f"Successfully resolved {host} to {ip}") + + # Simple socket connection check + with socket.create_connection((host, 443), timeout=5): + logger.info(f"Successfully connected to {host}:443") + except Exception as e: + logger.error(f"Network check failed for {host}: {e}") + +class FinanceEmailer: + def __init__(self): + self.gmail_user = os.environ.get('GMAIL_USER') + self.gmail_app_password = os.environ.get('GMAIL_APP_PASSWORD') + self.recipient_email = os.environ.get('RECIPIENT_EMAIL') + + if not all([self.gmail_user, self.gmail_app_password, self.recipient_email]): + raise ValueError("Missing required environment variables") + + def get_exchange_rate(self): + """Fetch AUD to USD exchange rate using Frankfurter API""" + try: + url = "https://api.frankfurter.app/latest?from=AUD&to=USD" + response = requests.get(url, timeout=10) + response.raise_for_status() + data = response.json() + return data['rates']['USD'] + except Exception as e: + logger.error(f"Error fetching exchange rate: {e}") + return None + + def get_hpe_stock_price(self): + """Fetch HPE stock price using yfinance""" + try: + logger.info("Attempting to fetch HPE stock data...") + + # Let yfinance handle the session + stock = yf.Ticker("HPE") + logger.info(f"Created ticker object for HPE") + + # Try to get data from the last 5 days to handle weekends/holidays + logger.info("Fetching 5-day history...") + try: + data = stock.history(period="5d") + except Exception as history_err: + logger.error(f"stock.history() raised an exception: {history_err}", exc_info=True) + data = pd.DataFrame() # Treat as empty + + logger.info(f"Data received - Empty: {data.empty}, Shape: {data.shape if not data.empty else 'N/A'}") + + if not data.empty: + logger.debug(f"Data preview:\n{data}") + latest_price = data['Close'].iloc[-1] + logger.info(f"Successfully fetched HPE price: ${latest_price}") + return round(latest_price, 2) + else: + logger.warning("DataFrame is empty - no data returned") + + # Try alternative method + logger.info("Trying alternative: stock.info...") + try: + # Force info fetch with error catching + info = stock.info + logger.debug(f"Info keys available: {list(info.keys())[:10]}...") + price = info.get('currentPrice') or info.get('regularMarketPrice') or info.get('previousClose') + if price: + logger.info(f"Got price from info: ${price}") + return round(price, 2) + else: + logger.warning(f"No valid price in info. currentPrice={info.get('currentPrice')}, regularMarketPrice={info.get('regularMarketPrice')}") + except Exception as info_err: + logger.error(f"stock.info failed with error: {info_err}", exc_info=True) + + logger.error("All methods failed - No stock data available") + return None + + except Exception as e: + logger.error(f"Error fetching HPE stock price: {e}", exc_info=True) + return None + + def send_email(self, aud_usd_rate, hpe_price): + """Send email with financial data""" + try: + # Format subject with data + aud_str = f"{aud_usd_rate:.4f}" if aud_usd_rate else "N/A" + hpe_str = f"${hpe_price}" if hpe_price else "N/A" + + msg = MIMEMultipart('alternative') + msg['Subject'] = f"AUD/USD: {aud_str} | HPE: {hpe_str} - Daily Update" + msg['From'] = self.gmail_user + msg['To'] = self.recipient_email + + # Create email body + text = f""" +Daily Financial Update +======================= +Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + +AUD to USD Exchange Rate: {aud_usd_rate if aud_usd_rate else 'N/A'} +HPE Stock Price: ${hpe_price if hpe_price else 'N/A'} + +--- +Automated daily update +""" + + html = f""" + + +

Daily Financial Update

+

Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+
+ + + + + + + + + + + + + +
MetricValue
AUD to USD{aud_usd_rate if aud_usd_rate else 'N/A'}
HPE Stock Price${hpe_price if hpe_price else 'N/A'}
+
+

Automated daily update

+ + +""" + + part1 = MIMEText(text, 'plain') + part2 = MIMEText(html, 'html') + msg.attach(part1) + msg.attach(part2) + + # Send email via Gmail SMTP + with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server: + server.login(self.gmail_user, self.gmail_app_password) + server.send_message(msg) + + logger.info(f"Email sent successfully") + return True + except Exception as e: + logger.error(f"Error sending email: {e}") + return False + + def run_daily_update(self): + """Fetch data and send email""" + logger.info(f"Running daily update") + check_network() + + aud_usd = self.get_exchange_rate() + hpe_price = self.get_hpe_stock_price() + + self.send_email(aud_usd, hpe_price) + +def main(): + emailer = FinanceEmailer() + + # Schedule daily at 9:00 AM + schedule.every().day.at("09:00").do(emailer.run_daily_update) + + # Run once immediately on startup + logger.info("Starting Finance Emailer...") + logger.info(f"System time: {datetime.now()}") + emailer.run_daily_update() + + # Keep running + while True: + schedule.run_pending() + time.sleep(60) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..49871d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests==2.31.0 +schedule==1.2.0 +yfinance>=0.2.36 +lxml>=4.9.0 \ No newline at end of file