Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import contextlib
import hmac
import ipaddress
import json
import logging
Expand Down Expand Up @@ -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")


Expand Down
20 changes: 12 additions & 8 deletions app/worker_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.fleet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
- "8080:8080"
volumes:
- cashpilot_data:/data
- cashpilot_fleet:/fleet
environment:
- TZ=${TZ:-UTC}
- CASHPILOT_SECRET_KEY=${CASHPILOT_SECRET_KEY}
Expand All @@ -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
Expand Down Expand Up @@ -79,3 +81,4 @@ services:
volumes:
cashpilot_data:
cashpilot_worker_data:
cashpilot_fleet:
1 change: 0 additions & 1 deletion requirements-worker.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading