Files
stackpilot/ROADMAP.md
T

10 KiB
Raw Blame History

StackPilot Roadmap — Phases 2123

Planned 2026-06-09. Status keys: ☐ not started · ◐ in progress · ☑ done. Each phase ships independently following the standing release checklist (bump backend/main.py + backend/agent_app.py AGENT_VERSION + frontend/package.json → build backend→agent→frontend :VERSION+:latest → py_compile + tsc -b && vite build → route smoke-test → push all 3 → README → git commit + push → update memory). Current released version: 0.26.0.

Order: 21 → 22 → 23 (Terminal is highest-value and self-contained; Secrets is most design-ambiguous, left last).


Phase 21 — Container terminal (web exec) ☐ NOT STARTED → target 0.27.0

Interactive shell into a running, compose-managed container over WebSocket + xterm.js, like Portainer's Console. Local + agent. Admin-only (exec is root-equivalent).

Backend

  • services/exec_service.py (new). Reuse container_service._get_managed() to refuse non-compose containers. Functions:
    • create_exec(container_id, cmd, tty=True) → low-level client.api.exec_create(container_id, cmd, stdin=True, tty=True, stdout=True, stderr=True) returns exec_id.
    • start_exec(exec_id)client.api.exec_start(exec_id, socket=True, tty=True, demux=False) returns the raw socket (a SocketIO/socket; on some docker-py versions the real fd is sock._sock — handle both; verify live, this is the main risk).
    • resize_exec(exec_id, h, w)client.api.exec_resize(exec_id, height=h, width=w).
    • default cmd = ["/bin/sh"] (frontend may request /bin/bash).
  • routers/ws.py: add _authorize_admin(websocket, token) (decode JWT like _authorize but also require role == "admin"; close 4403 if not admin). Then @router.websocket("/ws/exec/{container_id}"):
    • accept, _authorize_admin, read ?cmd= (default /bin/sh), create+start exec.
    • Bidirectional pump with two asyncio tasks:
      • reader: loop.run_in_executor(None, sock.recv, 4096)send_bytes/ send_text to browser (decode utf-8, errors="replace").
      • writer: await websocket.receive_text(); JSON control msgs {type:"resize",cols,rows}resize_exec; otherwise raw keystrokes → sock.sendall(data.encode()) (also in executor).
      • on first task to finish, cancel the other; close sock + ws.
    • audit container.exec.
  • agent_app.py: @app.websocket("/agent/ws/exec/{container_id}") — token via ?token= vs AGENT_TOKEN, same pump (import exec_service).
  • routers/ws.py proxy: @router.websocket("/ws/agent-exec/{agent_id}/{container_id}") — mirror ws_agent_deploy BUT forward both directions (agent-logs/deploy only pump upstream→browser; exec also needs browser→upstream). Use urllib.parse.quote(token, safe=''), InvalidStatus 404 → "update agent" hint.

Frontend

  • ☐ Add deps to frontend/package.json: xterm (@xterm/xterm) + @xterm/addon-fit. (rebuild installs them).
  • components/stacks/ContainerTerminal.tsx — modal: xterm.js Terminal + FitAddon, connects to /ws/exec/{id} or /ws/agent-exec/{aid}/{id}; shell <select> (/bin/sh | /bin/bash); term.onData → ws.send; ws.onmessage → term.write; ResizeObserver/fit → send {type:"resize",cols,rows}; status line (connected/closed/error 4403 → "admin only").
  • ContainerCard.tsx: add a Terminal button (admin + running only) next to start/stop/restart → opens ContainerTerminal with the same agentId.

Verify

  • ☐ Live: exec /bin/sh into a real throwaway compose container, run ls/pwd, type interactively, resize, Ctrl-C, exit. Bad/non-admin token → 4403.
  • ☐ Agent path: same against a local agent container w/ docker.sock.
  • ☐ py_compile + tsc/vite build + route smoke-test (expect main +2 WS, agent +1).

Open risks

  • docker-py exec_start(socket=True) socket object shape varies by version — verify .recv/.sendall vs needing sock._sock. This is THE thing to nail live.
  • TTY mode (tty=True) gives a raw single stream (no 8-byte demux header) — right for xterm. If we ever do non-tty, we'd need demux handling.

Phase 22 — Auto-Update (Watchtower-style) ☐ NOT STARTED → target 0.28.0

Per-stack policy: when an image used by the stack has a newer registry digest, either auto pull+redeploy or just notify. Builds on the existing update_service (digest check) + compose_service.pull/up + notify_service.

Backend

  • models/auto_update.pyAutoUpdate(id, stack_id, agent_id nullable, enabled bool, redeploy bool [True=pull+up / False=notify-only], last_run, last_status, last_result). (agent_id None = local stack.)
  • services/auto_update_service.py:
    • run_due() — for each enabled policy: resolve the stack's images (local: update_service images for that compose project; remote: via agent), check_image each; if any update_available:
      • redeploy=True local → compose_service.pull(stack_id) then compose_service.up(stack_id); remote → agent_service POST /agent/stacks/{id}/update.
      • redeploy=False → notify only.
      • record last_run/last_status (updated/up-to-date/error), notify EVENT_STACK_AUTO_UPDATED (add to models/setting.py ALL_EVENTS + frontend EVENT_LABELS) on redeploy, reuse pull_failed/stack_error on failure.
    • Hook into the existing update_service.background_loop (it already polls on the settings interval) — after check_all(), call auto_update_service.run_due(). (Or the scheduler_loop; pick background_loop since it already has fresh digest data.)
  • routers/stacks.py: GET /api/stacks/{id}/auto-update + PUT /api/stacks/{id}/auto-update (admin). Agent stacks: add to routers/agents.py GET/PUT /api/agents/{id}/stacks/{sid}/auto-update (policy stored centrally keyed by agent_id+stack_id; the agent itself stays stateless — central drives it).

Frontend

  • StackDetail.tsx + RemoteStackDetail.tsx: an Auto-update toggle in the action bar / a small card (enabled + mode redeploy|notify-only). api/stacks.ts
    • api/agents.ts get/set.
  • ☐ Optional: Settings → Auto-updates section listing all policies w/ last run + status (like Scheduled backups).

Verify

  • ☐ Enable on a throwaway stack pinned to an old tag, point its tag at a newer digest (or use a 2-tag trick), confirm run_due pulls + redeploys + records status + fires the notification. Notify-only mode notifies without redeploy.
  • ☐ py_compile + build + route smoke-test.

Open risks

  • Mapping a stack → its images reliably (compose image: refs vs built images; built/untagged images can't be digest-checked — skip them).
  • Don't redeploy a stack the user has manually stopped (check status first? decide at impl: probably only auto-update stacks currently running).

Phase 23 — Docker Secrets & Configs ☐ NOT STARTED → target 0.29.0

DESIGN DECISION TO MAKE FIRST (ask user / decide at phase start): Docker secret/config objects are a Swarm feature. Two interpretations:

  • (A) Compose file-based secrets (secrets: top-level with file: + per-service secrets:) — works in plain compose, the relevant one for a compose manager. Recommended default.
  • (B) Swarm secrets/configs via client.secrets/client.configs — only if swarm mode is active (detect client.info()["Swarm"]["LocalNodeState"]=="active").

Plan assumes (A), with (B) surfaced only when swarm is detected.

Backend

  • models/secret.pyManagedSecret(id, name, scope [global|stack], stack_id nullable, kind [secret|config], created, agent_id nullable). Content NOT in DB — stored on disk.
  • services/secret_service.py:
    • store secret files under a sandboxed dir <STACKS_DIR>/.stackpilot-secrets/ (chmod 700 dir, 600 files); create(name, content), update(name, content), delete(name), list() (metadata only — never return content; mask), and path_for(name) for compose file: refs.
    • if swarm active: also expose client.secrets.list/create/remove + client.configs.* (interpretation B), behind a swarm_active() guard.
  • compose_edit_service: add_secret(yaml, service, secret_name) — injects top-level secrets: {<name>: {file: <path>}} + per-service secrets: [<name>]; remove_secret(...). Same for configs.
  • routers/secrets.py (prefix /api/secrets): CRUD (admin, audit secret.*), content write-only. Agent /agent/secrets/* + proxy /api/agents/{id}/secrets/* for multi-host (reuse the patterns).

Frontend

  • ☐ Settings → Secrets & Configs section (or a dedicated page): list (name, scope, kind, created), create (name + content textarea, content masked after), delete. Multi-host host-switcher like Files.
  • ☐ Editor helper panel: a Secrets wizard tab to attach an existing secret/ config to a service (writes the compose secrets: block via compose_edit_service).

Verify

  • ☐ Create a file-based secret, attach to a service via the wizard, deploy, exec in and confirm /run/secrets/<name> is present with the content.
  • ☐ Sandbox: secret files can't escape the secrets dir; content never returned by list.
  • ☐ If swarm active on the build host, smoke-test the swarm path too (likely NOT active here → note as hardware-verify debt).
  • ☐ py_compile + build + route smoke-test.

Open risks

  • Swarm-vs-compose decision (above). Confirm with user before coding.
  • Secret file ownership/permissions inside the container vs on host (file-based secrets mount the host file → uid/gid must be readable by the service user).
  • HOST_ROOT_PREFIX / multi-host: the secret file must live where that host's Docker daemon can read it (agent stores on its own host).

After 23

Remaining un-built ideas from the gap analysis (not chosen this round): Health-monitoring & alerting (Docker-events → notify; note /ws/events already exists), per-container live-stats + historical graphs, GitOps/deploy-from-Git, image-management expansion (pull/remove/build arbitrary images + registry creds UI), 2FA/TOTP + OIDC SSO + API tokens.