diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 53ea64a..c93f3b1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -48,8 +48,8 @@ body: id: version attributes: label: CashPilot Version - description: "Image tag or git commit (e.g. v0.2.17, latest)" - placeholder: "latest" + description: "Exact image tag from your docker-compose.yml or `docker inspect` output (do NOT write 'latest')" + placeholder: "v0.2.72" validations: required: true @@ -75,7 +75,7 @@ body: attributes: label: Checks options: - - label: I am using the latest version of CashPilot + - label: I have checked whether this is already fixed in the latest version required: true - label: I searched existing issues and this is not a duplicate required: true diff --git a/AGENTS.md b/AGENTS.md index daeeab5..57fc332 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,14 @@ This is how Portainer works. The worker is a dumb executor — it never decrypts **Flow:** Push to main → auto-release v0.1.x → tag triggers Docker build → versioned images on Docker Hub. +> **CRITICAL: NEVER manually create tags or GitHub releases.** The `release.yml` workflow handles version bumping, tagging, and release creation automatically on merge to main. Manually creating a tag/release causes `already_exists` conflicts and skips the Docker image build job. Just merge the PR and let CI do everything. + +**Deployment workflow after merging a PR:** +1. Merge PR to main +2. Wait for `Auto Release` workflow to complete (creates tag + release) +3. Wait for `Build and Push Docker Images` workflow to complete (pushes to Docker Hub) +4. On the server: `docker pull drumsergio/cashpilot:latest` + recreate container + **Always use tagged images in deployment** (e.g. `drumsergio/cashpilot:0.1.1`), never `:latest`. **Required GitHub Secrets:** diff --git a/Dockerfile.worker b/Dockerfile.worker index 66b97bb..0be26e1 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -40,6 +40,7 @@ WORKDIR /app COPY --chown=cashpilot:root app/__init__.py ./app/ COPY --chown=cashpilot:root app/constants.py ./app/ +COPY --chown=cashpilot:root app/fleet_key.py ./app/ COPY --chown=cashpilot:root app/worker_api.py ./app/ COPY --chown=cashpilot:root app/orchestrator.py ./app/ COPY --chown=cashpilot:root app/catalog.py ./app/ diff --git a/app/main.py b/app/main.py index b858705..b68c1b3 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ import asyncio import contextlib +import hmac import ipaddress import json import logging @@ -1537,7 +1538,7 @@ def _verify_fleet_api_key(request: Request) -> None: detail="Fleet key not configured — set CASHPILOT_API_KEY or mount shared /fleet volume", ) auth_header = request.headers.get("Authorization", "") - if auth_header != f"Bearer {FLEET_API_KEY}": + if not hmac.compare_digest(auth_header.encode(), f"Bearer {FLEET_API_KEY}".encode()): raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/app/worker_api.py b/app/worker_api.py index de9394e..4192597 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -14,11 +14,13 @@ import asyncio import contextlib +import hmac import logging import os import platform import socket from contextlib import asynccontextmanager +from datetime import UTC, datetime from html import escape as _esc from typing import Any @@ -64,7 +66,7 @@ def _verify_api_key(request: Request) -> None: if not API_KEY: raise HTTPException(status_code=503, detail="Fleet key not configured") auth = request.headers.get("Authorization", "") - if auth != f"Bearer {API_KEY}": + if not hmac.compare_digest(auth.encode(), f"Bearer {API_KEY}".encode()): raise HTTPException(status_code=401, detail="Invalid API key") @@ -102,7 +104,7 @@ async def _send_heartbeat() -> None: ) resp.raise_for_status() _ui_connected = True - _last_heartbeat = "just now" + _last_heartbeat = datetime.now(UTC).strftime("%H:%M:%S UTC") _last_error = "" logger.debug("Heartbeat sent to %s", UI_URL) except Exception as exc: @@ -124,10 +126,11 @@ def _get_local_ip() -> str: """Best-effort local IP detection for worker URL.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + finally: + s.close() except Exception: return socket.gethostname() @@ -309,8 +312,9 @@ async def api_deploy_container(request: Request, slug: str, spec: DeploySpec) -> labels=spec.labels, ) return {"status": "deployed", "container_id": container_id} - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) + except Exception: + logger.exception("Deploy failed for %s", slug) + raise HTTPException(status_code=500, detail="Container deployment failed") @app.post("/api/containers/{slug}/restart") diff --git a/docker-compose.fleet.yml b/docker-compose.fleet.yml index fe549a0..ba316f8 100644 --- a/docker-compose.fleet.yml +++ b/docker-compose.fleet.yml @@ -16,6 +16,7 @@ services: - "8080:8080" volumes: - cashpilot_data:/data + - cashpilot_fleet:/fleet environment: - TZ=${TZ:-UTC} - CASHPILOT_SECRET_KEY=${CASHPILOT_SECRET_KEY} @@ -35,6 +36,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - cashpilot_worker_data:/data + - cashpilot_fleet:/fleet environment: - TZ=${TZ:-UTC} - CASHPILOT_UI_URL=http://cashpilot-ui:8080 @@ -79,3 +81,4 @@ services: volumes: cashpilot_data: cashpilot_worker_data: + cashpilot_fleet: diff --git a/requirements-worker.txt b/requirements-worker.txt index 81bb7a5..6c7a132 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -3,6 +3,5 @@ uvicorn>=0.34 uvloop>=0.21 httptools>=0.6 docker>=7.0 -aiosqlite>=0.20 httpx>=0.28 pyyaml>=6.0