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
+89 -2
View File
@@ -32,8 +32,9 @@ def _dump(data: dict) -> str:
# Drop empty top-level keys we may have created.
if not data.get("services"):
data.pop("services", None)
if "volumes" in data and not data["volumes"]:
data.pop("volumes", None)
for key in ("volumes", "secrets", "configs"):
if key in data and not data[key]:
data.pop(key, None)
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
@@ -208,3 +209,89 @@ def set_resources(
def list_services(yaml_str: str) -> list[str]:
data = _load(yaml_str)
return list(data["services"].keys())
# --------------------------------------------------------------------------- #
# Secrets & configs (compose file-based)
# --------------------------------------------------------------------------- #
def add_secret(yaml_str: str, service: str, name: str, file_path: str) -> str:
"""Define a top-level file-based secret and attach it to ``service``."""
data = _load(yaml_str)
svc = _get_service(data, service)
secrets = data.setdefault("secrets", {})
if not isinstance(secrets, dict):
raise EditError("`secrets` must be a mapping")
secrets[name] = {"file": file_path}
refs = svc.setdefault("secrets", [])
if not isinstance(refs, list):
raise EditError(f"Service '{service}' secrets must be a list")
if name not in refs:
refs.append(name)
return _dump(data)
def remove_secret(yaml_str: str, service: str, name: str) -> str:
"""Detach a secret from ``service``; drop the top-level def if now unused."""
data = _load(yaml_str)
svc = data["services"].get(service)
if isinstance(svc, dict) and isinstance(svc.get("secrets"), list):
svc["secrets"] = [s for s in svc["secrets"] if s != name]
if not svc["secrets"]:
svc.pop("secrets", None)
_prune_top_level(data, "secrets", name, _secret_still_used)
return _dump(data)
def add_config(yaml_str: str, service: str, name: str, file_path: str, target: str) -> str:
"""Define a top-level file-based config and mount it into ``service`` at target."""
data = _load(yaml_str)
svc = _get_service(data, service)
configs = data.setdefault("configs", {})
if not isinstance(configs, dict):
raise EditError("`configs` must be a mapping")
configs[name] = {"file": file_path}
refs = svc.setdefault("configs", [])
if not isinstance(refs, list):
raise EditError(f"Service '{service}' configs must be a list")
if not any(isinstance(e, dict) and e.get("source") == name for e in refs):
refs.append({"source": name, "target": target})
return _dump(data)
def remove_config(yaml_str: str, service: str, name: str) -> str:
data = _load(yaml_str)
svc = data["services"].get(service)
if isinstance(svc, dict) and isinstance(svc.get("configs"), list):
svc["configs"] = [
e for e in svc["configs"]
if not (e == name or (isinstance(e, dict) and e.get("source") == name))
]
if not svc["configs"]:
svc.pop("configs", None)
_prune_top_level(data, "configs", name, _config_still_used)
return _dump(data)
def _secret_still_used(data: dict, name: str) -> bool:
for svc in data.get("services", {}).values():
if isinstance(svc, dict) and name in (svc.get("secrets") or []):
return True
return False
def _config_still_used(data: dict, name: str) -> bool:
for svc in data.get("services", {}).values():
if not isinstance(svc, dict):
continue
for entry in svc.get("configs") or []:
if entry == name or (isinstance(entry, dict) and entry.get("source") == name):
return True
return False
def _prune_top_level(data: dict, key: str, name: str, still_used) -> None:
top = data.get(key)
if isinstance(top, dict) and name in top and not still_used(data, name):
top.pop(name, None)