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:
co-authored by
Claude Opus 4.8
parent
255c8441c6
commit
6464e0677c
+51
-42
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user