Skip to content

Commit fb6da9a

Browse files
committed
Implement CASHPILOT_MODE=ui for Docker-free UI container
- UI container can now run without Docker socket (CASHPILOT_MODE=ui) - All 17 orchestrator call sites gated by _ui_only flag - Health checks use worker heartbeat data instead of Docker socket - Container management routes require worker_id in UI mode - Deploy route proxies full spec to workers via _proxy_worker_deploy() - _proxy_worker_command now supports DELETE for remove operations - Fleet/earnings summaries use worker data in UI mode - Standalone mode (default) unchanged — full backwards compatibility
1 parent 9a89638 commit fb6da9a

2 files changed

Lines changed: 182 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,30 @@ This is how Portainer works. The worker is a dumb executor — it never decrypts
261261

262262
## CI/CD
263263

264+
### `release.yml` -- Auto Release
265+
266+
**Triggers:** Push to `main` (paths: `app/`, `services/`, `Dockerfile*`, `requirements*.txt`)
267+
268+
**What it does:**
269+
1. Reads the latest `v*.*.*` tag
270+
2. Auto-increments patch version (e.g. `v0.1.0``v0.1.1`)
271+
3. Creates annotated git tag + GitHub Release with auto-generated notes
272+
4. Skips if commit message contains `[skip ci]`
273+
264274
### `build.yml` -- Docker Build & Push
265275

266-
**Triggers:** Push to `main` or version tags (`v*`)
276+
**Triggers:** Version tags (`v*`) — created by `release.yml` or manually
267277

268278
**What it does:**
269-
1. Builds multi-arch image (linux/amd64 + linux/arm64) via QEMU + Buildx
270-
2. Pushes to Docker Hub as `drumsergio/cashpilot` (UI) and `drumsergio/cashpilot-worker`
271-
3. Tags: `latest` on main, semver on tags (`v1.0.0` -> `1.0.0` + `1.0`)
272-
4. Layer caching via GitHub Actions cache
279+
1. Lints with ruff
280+
2. Builds multi-arch images (linux/amd64 + linux/arm64) via QEMU + Buildx
281+
3. Pushes to Docker Hub as `drumsergio/cashpilot` (UI) and `drumsergio/cashpilot-worker`
282+
4. Tags: `latest` + semver (`v1.0.0``1.0.0` + `1.0`)
283+
5. Layer caching via GitHub Actions cache
284+
285+
**Flow:** Push to main → auto-release v0.1.x → tag triggers Docker build → versioned images on Docker Hub.
286+
287+
**Always use tagged images in deployment** (e.g. `drumsergio/cashpilot:0.1.1`), never `:latest`.
273288

274289
**Required GitHub Secrets:**
275290
- `DOCKERHUB_USERNAME`
@@ -408,7 +423,7 @@ volumes:
408423
| Variable | Default | Description |
409424
|----------|---------|-------------|
410425
| `TZ` | `UTC` | Timezone |
411-
| `CASHPILOT_MODE` | `standalone` | `standalone` (UI+Worker), `ui` (UI only), or `worker` (Worker only) |
426+
| `CASHPILOT_MODE` | `standalone` | `standalone` (UI+Worker with Docker socket), `ui` (UI only — no Docker socket, all ops via workers), or `worker` (Worker only) |
412427
| `CASHPILOT_SECRET_KEY` | Auto-generated | Fernet encryption key for credentials (UI/standalone only) |
413428
| `CASHPILOT_COLLECTION_INTERVAL` | `3600` | Seconds between earnings collection (UI/standalone only) |
414429
| `CASHPILOT_PORT` | `8080` | Web UI port (UI/standalone) or mini-UI port (worker, default 8081) |

app/main.py

Lines changed: 161 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,49 @@
3434
# In-memory store for the latest collector alerts (errors from last run)
3535
_collector_alerts: list[dict[str, str]] = []
3636

37+
# UI-only mode: no Docker socket, all container ops go through workers
38+
_ui_only: bool = os.getenv("CASHPILOT_MODE", "standalone").lower() == "ui"
39+
40+
41+
async def _get_all_worker_containers() -> list[dict[str, Any]]:
42+
"""Collect container data from all online workers' heartbeat data in DB."""
43+
workers = await database.list_workers()
44+
result: list[dict[str, Any]] = []
45+
for w in workers:
46+
if w.get("status") != "online":
47+
continue
48+
sys_info = json.loads(w.get("system_info", "{}"))
49+
worker_has_docker = sys_info.get("docker_available", False)
50+
containers = json.loads(w.get("containers", "[]"))
51+
for c in containers:
52+
slug = c.get("slug", "")
53+
if slug:
54+
result.append(
55+
{
56+
"slug": slug,
57+
"name": c.get("name", slug),
58+
"status": c.get("status", "unknown"),
59+
"image": c.get("image", ""),
60+
"cpu_percent": c.get("cpu_percent", 0),
61+
"memory_mb": c.get("memory_mb", 0),
62+
"category": "",
63+
"deployed_by": w.get("name", "worker"),
64+
"_node": w.get("name", "worker"),
65+
"_worker_id": w.get("id"),
66+
"_has_docker": worker_has_docker,
67+
}
68+
)
69+
return result
70+
71+
72+
def _require_worker_id_in_ui_mode(worker_id: int | None) -> None:
73+
"""Raise 400 if running in UI mode and no worker_id was provided."""
74+
if _ui_only and worker_id is None:
75+
raise HTTPException(
76+
status_code=400,
77+
detail="worker_id is required in UI mode (no local Docker socket)",
78+
)
79+
3780

3881
# ---------------------------------------------------------------------------
3982
# Periodic collection job
@@ -48,7 +91,10 @@ async def _run_health_check() -> None:
4891
deployed on multiple nodes where one may be stopped).
4992
"""
5093
try:
51-
statuses = orchestrator.get_status()
94+
if _ui_only:
95+
statuses = await _get_all_worker_containers()
96+
else:
97+
statuses = orchestrator.get_status()
5298
# Aggregate: slug -> best status (running wins)
5399
slug_best: dict[str, str] = {}
54100
for s in statuses:
@@ -141,11 +187,13 @@ async def lifespan(app: FastAPI):
141187
scheduler.add_job(exchange_rates.refresh, "interval", minutes=15, id="exchange_rates")
142188
scheduler.start()
143189
await exchange_rates.refresh()
144-
docker_mode = "direct" if orchestrator.docker_available() else "monitor-only"
145-
logger.info("CashPilot started (Docker: %s)", docker_mode)
146-
147-
# Populate status cache immediately so first page load is instant
148-
asyncio.get_event_loop().run_in_executor(None, orchestrator.get_status)
190+
if _ui_only:
191+
logger.info("CashPilot started (mode: ui, no local Docker)")
192+
else:
193+
docker_mode = "direct" if orchestrator.docker_available() else "monitor-only"
194+
logger.info("CashPilot started (Docker: %s)", docker_mode)
195+
# Populate status cache immediately so first page load is instant
196+
asyncio.get_event_loop().run_in_executor(None, orchestrator.get_status)
149197

150198
yield
151199

@@ -428,6 +476,8 @@ async def page_settings(request: Request):
428476
async def api_mode(request: Request) -> dict[str, Any]:
429477
"""Return CashPilot operating mode and Docker availability."""
430478
_require_auth_api(request)
479+
if _ui_only:
480+
return {"docker": False, "mode": "ui"}
431481
has_docker = orchestrator.docker_available()
432482
return {
433483
"docker": has_docker,
@@ -450,16 +500,18 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
450500
details for the expandable sub-row UI.
451501
"""
452502
_require_auth_api(request)
453-
try:
454-
statuses = list(orchestrator.get_status_cached()) # copy — don't mutate cache
455-
except RuntimeError:
456-
statuses = []
457-
458-
# Tag local containers with node info
459-
for s in statuses:
460-
s["_node"] = "local"
461-
s["_worker_id"] = None
462-
s["_has_docker"] = orchestrator.docker_available()
503+
if _ui_only:
504+
statuses: list[dict[str, Any]] = []
505+
else:
506+
try:
507+
statuses = list(orchestrator.get_status_cached()) # copy — don't mutate cache
508+
except RuntimeError:
509+
statuses = []
510+
# Tag local containers with node info
511+
for s in statuses:
512+
s["_node"] = "local"
513+
s["_worker_id"] = None
514+
s["_has_docker"] = orchestrator.docker_available()
463515

464516
# Also include worker containers
465517
workers = await database.list_workers()
@@ -641,6 +693,8 @@ async def api_get_service(request: Request, slug: str) -> dict[str, Any]:
641693
@app.get("/api/status")
642694
async def api_status(request: Request) -> list[dict[str, Any]]:
643695
_require_auth_api(request)
696+
if _ui_only:
697+
return []
644698
try:
645699
return orchestrator.get_status_cached()
646700
except RuntimeError as exc:
@@ -653,11 +707,31 @@ class DeployRequest(BaseModel):
653707

654708

655709
@app.post("/api/deploy/{slug}")
656-
async def api_deploy(request: Request, slug: str, body: DeployRequest) -> dict[str, str]:
710+
async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id: int | None = None) -> dict[str, str]:
657711
_require_writer(request)
658712
svc = catalog.get_service(slug)
659713
if not svc:
660714
raise HTTPException(status_code=404, detail=f"Service '{slug}' not found")
715+
716+
if _ui_only or worker_id is not None:
717+
_require_worker_id_in_ui_mode(worker_id)
718+
# Build deploy spec from catalog and forward to worker
719+
docker_conf = svc.get("docker", {})
720+
image = docker_conf.get("image")
721+
if not image:
722+
raise HTTPException(status_code=400, detail=f"Service '{slug}' has no Docker image")
723+
spec = {
724+
"image": image,
725+
"env": body.env or {},
726+
"hostname": body.hostname,
727+
}
728+
result = await _proxy_worker_deploy(worker_id, slug, spec) # type: ignore[arg-type]
729+
container_id = result.get("container_id", "remote")
730+
await database.save_deployment(slug=slug, container_id=container_id)
731+
await database.record_health_event(slug, "start", f"deployed to worker {worker_id}")
732+
asyncio.create_task(_run_collection())
733+
return {"status": "deployed", "container_id": container_id}
734+
661735
try:
662736
container_id = orchestrator.deploy_service(
663737
slug=slug,
@@ -666,7 +740,6 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest) -> dict[s
666740
)
667741
await database.save_deployment(slug=slug, container_id=container_id)
668742
await database.record_health_event(slug, "start", "deployed")
669-
# Trigger collection so earnings show up quickly
670743
asyncio.create_task(_run_collection())
671744
return {"status": "deployed", "container_id": container_id}
672745
except RuntimeError as exc:
@@ -676,8 +749,11 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest) -> dict[s
676749

677750

678751
@app.post("/api/stop/{slug}")
679-
async def api_stop(request: Request, slug: str) -> dict[str, str]:
752+
async def api_stop(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]:
680753
_require_writer(request)
754+
_require_worker_id_in_ui_mode(worker_id)
755+
if worker_id is not None:
756+
return await _proxy_worker_command(worker_id, "stop", slug)
681757
try:
682758
orchestrator.stop_service(slug)
683759
return {"status": "stopped"}
@@ -688,8 +764,11 @@ async def api_stop(request: Request, slug: str) -> dict[str, str]:
688764

689765

690766
@app.post("/api/restart/{slug}")
691-
async def api_restart(request: Request, slug: str) -> dict[str, str]:
767+
async def api_restart(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]:
692768
_require_writer(request)
769+
_require_worker_id_in_ui_mode(worker_id)
770+
if worker_id is not None:
771+
return await _proxy_worker_command(worker_id, "restart", slug)
693772
try:
694773
orchestrator.restart_service(slug)
695774
return {"status": "restarted"}
@@ -700,8 +779,13 @@ async def api_restart(request: Request, slug: str) -> dict[str, str]:
700779

701780

702781
@app.delete("/api/remove/{slug}")
703-
async def api_remove(request: Request, slug: str) -> dict[str, str]:
782+
async def api_remove(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]:
704783
_require_writer(request)
784+
_require_worker_id_in_ui_mode(worker_id)
785+
if worker_id is not None:
786+
result = await _proxy_worker_command(worker_id, "remove", slug)
787+
await database.remove_deployment(slug)
788+
return result
705789
try:
706790
orchestrator.remove_service(slug)
707791
await database.remove_deployment(slug)
@@ -718,7 +802,7 @@ async def api_remove(request: Request, slug: str) -> dict[str, str]:
718802

719803

720804
async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict[str, str]:
721-
"""Forward a container command (restart/stop/start) to a worker."""
805+
"""Forward a container command (restart/stop/start/remove) to a worker."""
722806
worker = await database.get_worker(worker_id)
723807
if not worker:
724808
raise HTTPException(status_code=404, detail="Worker not found")
@@ -734,7 +818,33 @@ async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict
734818

735819
try:
736820
async with httpx.AsyncClient(timeout=30) as client:
737-
resp = await client.post(f"{url}/api/containers/{slug}/{command}", headers=headers)
821+
if command == "remove":
822+
resp = await client.delete(f"{url}/api/containers/{slug}", headers=headers)
823+
else:
824+
resp = await client.post(f"{url}/api/containers/{slug}/{command}", headers=headers)
825+
return resp.json()
826+
except httpx.HTTPError as exc:
827+
raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}")
828+
829+
830+
async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) -> dict[str, Any]:
831+
"""Forward a deploy command with full spec to a worker."""
832+
worker = await database.get_worker(worker_id)
833+
if not worker:
834+
raise HTTPException(status_code=404, detail="Worker not found")
835+
if worker["status"] != "online":
836+
raise HTTPException(status_code=503, detail="Worker is offline")
837+
if not worker["url"]:
838+
raise HTTPException(status_code=503, detail="Worker URL not known")
839+
840+
url = worker["url"].rstrip("/")
841+
headers = {}
842+
if FLEET_API_KEY:
843+
headers["Authorization"] = f"Bearer {FLEET_API_KEY}"
844+
845+
try:
846+
async with httpx.AsyncClient(timeout=60) as client:
847+
resp = await client.post(f"{url}/api/containers/{slug}/deploy", json=spec, headers=headers)
738848
return resp.json()
739849
except httpx.HTTPError as exc:
740850
raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}")
@@ -777,6 +887,7 @@ async def api_service_restart(request: Request, slug: str, worker_id: int | None
777887
_require_writer(request)
778888
if worker_id is not None:
779889
return await _proxy_worker_command(worker_id, "restart", slug)
890+
_require_worker_id_in_ui_mode(worker_id)
780891
try:
781892
orchestrator.restart_service(slug)
782893
await database.record_health_event(slug, "restart")
@@ -792,6 +903,7 @@ async def api_service_stop(request: Request, slug: str, worker_id: int | None =
792903
_require_writer(request)
793904
if worker_id is not None:
794905
return await _proxy_worker_command(worker_id, "stop", slug)
906+
_require_worker_id_in_ui_mode(worker_id)
795907
try:
796908
orchestrator.stop_service(slug)
797909
await database.record_health_event(slug, "stop")
@@ -807,6 +919,7 @@ async def api_service_start(request: Request, slug: str, worker_id: int | None =
807919
_require_writer(request)
808920
if worker_id is not None:
809921
return await _proxy_worker_command(worker_id, "start", slug)
922+
_require_worker_id_in_ui_mode(worker_id)
810923
try:
811924
orchestrator.start_service(slug)
812925
await database.record_health_event(slug, "start")
@@ -824,6 +937,7 @@ async def api_service_logs(
824937
_require_auth_api(request)
825938
if worker_id is not None:
826939
return await _proxy_worker_logs(worker_id, slug, lines)
940+
_require_worker_id_in_ui_mode(worker_id)
827941
try:
828942
logs = orchestrator.get_service_logs(slug, lines=min(lines, 1000))
829943
return {"logs": logs}
@@ -834,8 +948,13 @@ async def api_service_logs(
834948

835949

836950
@app.delete("/api/services/{slug}")
837-
async def api_service_remove(request: Request, slug: str) -> dict[str, str]:
951+
async def api_service_remove(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]:
838952
_require_writer(request)
953+
_require_worker_id_in_ui_mode(worker_id)
954+
if worker_id is not None:
955+
result = await _proxy_worker_command(worker_id, "remove", slug)
956+
await database.remove_deployment(slug)
957+
return result
839958
try:
840959
orchestrator.remove_service(slug)
841960
await database.remove_deployment(slug)
@@ -913,11 +1032,15 @@ async def api_earnings_summary(request: Request) -> dict[str, Any]:
9131032
if usd_val is not None:
9141033
summary["total"] = round(summary["total"] + usd_val, 2)
9151034

916-
# Count active (running) services — use cached data for instant response
1035+
# Count active (running) services
9171036
active = 0
9181037
try:
919-
statuses = orchestrator.get_status_cached()
920-
active = sum(1 for s in statuses if s.get("status") == "running")
1038+
if _ui_only:
1039+
worker_containers = await _get_all_worker_containers()
1040+
active = sum(1 for s in worker_containers if s.get("status") == "running")
1041+
else:
1042+
statuses = orchestrator.get_status_cached()
1043+
active = sum(1 for s in statuses if s.get("status") == "running")
9211044
except Exception:
9221045
pass
9231046
summary["active_services"] = active
@@ -1287,17 +1410,19 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]:
12871410
if w["status"] == "online":
12881411
online_workers += 1
12891412

1290-
# Add local containers (cached for instant response)
1291-
try:
1292-
local_status = orchestrator.get_status_cached()
1293-
total_containers += len(local_status)
1294-
total_running += sum(1 for c in local_status if c.get("status") == "running")
1295-
except Exception:
1296-
pass
1413+
# Add local containers (cached for instant response) — skip in UI mode
1414+
if not _ui_only:
1415+
try:
1416+
local_status = orchestrator.get_status_cached()
1417+
total_containers += len(local_status)
1418+
total_running += sum(1 for c in local_status if c.get("status") == "running")
1419+
except Exception:
1420+
pass
12971421

1422+
local_offset = 0 if _ui_only else 1
12981423
return {
1299-
"total_workers": len(workers) + 1, # +1 for local
1300-
"online_workers": online_workers + 1,
1424+
"total_workers": len(workers) + local_offset,
1425+
"online_workers": online_workers + local_offset,
13011426
"total_containers": total_containers,
13021427
"running_containers": total_running,
13031428
}

0 commit comments

Comments
 (0)