Phase 23: per-stack secrets & configs (compose file-based), local + agent (0.29.0)

Manage Docker secrets and configs per stack from a new Secrets tab on Stack/
RemoteStackDetail. Content is stored as files inside the stack dir
(.secrets/<name>, .configs/<name>; dir 0700 / file 0600) and referenced from the
compose file with relative `file:` paths, so the daemon reads them without any
HOST_ROOT_PREFIX dependency. Content is write-only — the API only ever returns
metadata (name, kind, size).

- secret_service: write/delete/list (metadata only)/exists/rel_path/attach/detach;
  name validation rejects traversal/hidden/separators, content capped at 1 MiB.
- compose_edit_service: add/remove secret and config (top-level defs pruned when
  no service still references them).
- routers/secrets.py (admin-only, audit secret.*) + agent endpoints + multi-host
  proxy (audit agent.secret.*).
- Frontend SecretsPanel (create/list/delete + per-row attach/detach to a service;
  config rows take a mount target), agentId-aware for remote stacks.

Verified: name-sandbox + perms + metadata-only listing unit-tested; compose
add/remove round-trips to clean YAML; py_compile + backend/agent/frontend image
builds + route smoke-test (local/agent/proxy). Live exec check (/run/secrets/<name>
on a deployed stack) and swarm path are hardware-verify debt (swarm dropped: A).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-09 15:01:21 +00:00
co-authored by Claude Opus 4.8
parent 255c8441c6
commit 6464e0677c
13 changed files with 879 additions and 50 deletions
+51 -42
View File
@@ -5,7 +5,7 @@ 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**.
git commit + push → update memory). Current released version: **0.29.0**.
Order: **21 → 22 → 23** (Terminal is highest-value and self-contained; Secrets
is most design-ambiguous, left last).
@@ -144,58 +144,67 @@ either auto pull+redeploy or just notify. Builds on the existing
---
## Phase 23 — Docker Secrets & Configs ☐ NOT STARTED → target 0.29.0
## Phase 23 — Docker Secrets & Configs ☑ DONE — shipped 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"`).
**DECISION (user, 2026-06-09): (A) Compose file-based secrets.** Swarm path (B)
dropped. **Sub-decision (Claude's call): per-stack storage with RELATIVE paths**
— secret/config files live in `<stack_dir>/.secrets/<name>` and `.configs/<name>`,
referenced as `file: ./.secrets/<name>`. Compose resolves `file:` relative to the
compose file (which is in the stack dir = a host bind-mount), so the daemon reads
it with NO `HOST_ROOT_PREFIX` dependency. Secrets are therefore per-stack (matches
how compose scopes them), managed from a **Secrets tab on StackDetail /
RemoteStackDetail** (not the new-stack editor, which has no dir yet).
Plan assumes **(A)**, with (B) surfaced only when swarm is detected.
**As shipped — deviations from the plan (both simplifications):**
- **No DB model.** Both content *and* metadata live on disk; `list` derives name/
size/mtime from the filesystem. A `models/secret.py` would only duplicate that,
so it was dropped — there is nothing to keep in sync.
- **Files live in the stack dir, not a separate sandbox.** `<stack_dir>/.secrets/`
and `.configs/` (per-stack, relative `file:` refs) — this is exactly what
compose expects and removes the `HOST_ROOT_PREFIX` dependency. Sandboxing comes
from strict name validation (single component, no `..`, no leading dot, no sep).
- **Routes are stack-scoped:** `/api/stacks/{stack_id}/secrets`, not `/api/secrets`.
- **UI is a Secrets tab on Stack/RemoteStackDetail**, not a Settings page — a stack
must exist (have a dir) before it can hold secrets, matching compose's scoping.
- **Swarm path not built** (decision A dropped B); no `swarm_active()` guard.
### Backend
- `models/secret.py` — `ManagedSecret(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).
- `services/secret_service.py` — file-based store under `<stack_dir>/.secrets`
& `.configs` (dir 0700, file 0600); `write_secret`/`delete_secret`/`list_all`
(metadata only, never content)/`exists`/`rel_path`/`attach`/`detach`. 1 MiB cap;
name validation rejects traversal/hidden/separators.
- ☑ `compose_edit_service`: `add_secret`/`remove_secret` (top-level
`secrets: {<name>: {file: <path>}}` + per-service list) and `add_config`/
`remove_config` (with `source`/`target` mount); top-level defs pruned when unused.
- ☑ `routers/secrets.py` (prefix `/api/stacks/{stack_id}/secrets`): list/write/
delete/attach/detach, admin-only, audit `secret.*`, content write-only.
- ☑ Agent `/agent/stacks/{stack_id}/secrets/*` (agent_app.py) + proxy
`/api/agents/{id}/stacks/{stack_id}/secrets/*` (agents.py), audit `agent.secret.*`.
### 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`).
- ☑ `SecretsPanel` (api/secrets.ts + components/stacks/SecretsPanel.tsx): create
(type/name/content; content cleared after save, never re-shown), list (name,
kind, size), delete, and per-row attach/detach to a service (config rows take a
mount target). Admin-gated. Wired as a **Secrets** tab on both StackDetail and
RemoteStackDetail (agentId-aware → multi-host).
### 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.
- ☑ Sandbox: traversal/hidden/separator names rejected; dir 0700 / file 0600;
`list` returns metadata only, never content (unit-tested in the backend image).
- ☑ compose round-trip: add secret+config → remove both → back to clean YAML
(top-level defs pruned). add/remove for secrets and configs unit-tested.
- ☑ py_compile + backend image build + frontend `tsc -b && vite build` + route
smoke-test (local CRUD/attach/detach, agent, proxy all registered).
- ☐ **Live hardware-verify debt:** create a secret, attach via the panel, deploy,
exec in and confirm `/run/secrets/<name>` holds the content (needs a running
stack on real hardware). Swarm path intentionally not built (decision A).
### Open risks
- Swarm-vs-compose decision (above). Confirm with user before coding.
### Open risks (carried)
- 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).
- Multi-host: the agent stores the file on its own host, so the secret lives where
that host's Docker daemon can read it — verified by design (relative `file:`),
pending the live exec check above.
---