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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Per-stack, file-based compose secrets & configs.
|
||||
|
||||
Secret/config payloads are stored as files INSIDE the stack's own directory
|
||||
(``<stack_dir>/.secrets/<name>`` and ``.configs/<name>``) and referenced from
|
||||
the compose file with a RELATIVE path (``file: ./.secrets/<name>``). Because
|
||||
compose resolves ``file:`` relative to the compose file — which lives in the
|
||||
stack dir (a host bind-mount) — the Docker daemon reads the file correctly with
|
||||
no ``HOST_ROOT_PREFIX`` dependency, exactly as if the user had dropped a secret
|
||||
file next to their ``compose.yaml`` by hand.
|
||||
|
||||
Content is never returned by the listing API; only metadata (name, size, mtime).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from services import compose_edit_service, compose_service
|
||||
|
||||
KIND_DIR = {"secret": ".secrets", "config": ".configs"}
|
||||
_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
MAX_SECRET_BYTES = 1 * 1024 * 1024 # 1 MiB
|
||||
|
||||
|
||||
class SecretError(Exception):
|
||||
"""Invalid secret request (bad kind/name, too large)."""
|
||||
|
||||
|
||||
def _check_kind(kind: str) -> str:
|
||||
if kind not in KIND_DIR:
|
||||
raise SecretError(f"unknown kind '{kind}'")
|
||||
return kind
|
||||
|
||||
|
||||
def _check_name(name: str) -> str:
|
||||
# A single path component, no traversal, no leading dot (keeps it out of the
|
||||
# way of .secrets/.configs themselves and hidden-file surprises).
|
||||
if not name or name.startswith(".") or not _NAME_RE.match(name) or os.path.sep in name:
|
||||
raise SecretError("name must match [A-Za-z0-9._-] and not start with a dot")
|
||||
return name
|
||||
|
||||
|
||||
def kind_dir(stack_id: str, kind: str, override: Optional[str] = None) -> str:
|
||||
_check_kind(kind)
|
||||
return os.path.join(compose_service.stack_dir(stack_id, override), KIND_DIR[kind])
|
||||
|
||||
|
||||
def _path(stack_id: str, kind: str, name: str, override: Optional[str] = None) -> str:
|
||||
return os.path.join(kind_dir(stack_id, kind, override), _check_name(name))
|
||||
|
||||
|
||||
def rel_path(kind: str, name: str) -> str:
|
||||
"""Path to put in the compose ``file:`` field (relative to the compose file)."""
|
||||
return f"./{KIND_DIR[_check_kind(kind)]}/{_check_name(name)}"
|
||||
|
||||
|
||||
def list_secrets(stack_id: str, kind: str, override: Optional[str] = None) -> list[dict]:
|
||||
directory = kind_dir(stack_id, kind, override)
|
||||
if not os.path.isdir(directory):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for name in sorted(os.listdir(directory)):
|
||||
full = os.path.join(directory, name)
|
||||
if os.path.isfile(full):
|
||||
st = os.stat(full)
|
||||
out.append({"name": name, "kind": kind, "size": st.st_size, "modified": st.st_mtime})
|
||||
return out
|
||||
|
||||
|
||||
def list_all(stack_id: str, override: Optional[str] = None) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for kind in KIND_DIR:
|
||||
items.extend(list_secrets(stack_id, kind, override))
|
||||
return items
|
||||
|
||||
|
||||
def write_secret(stack_id: str, kind: str, name: str, content: str, override: Optional[str] = None) -> dict:
|
||||
_check_kind(kind)
|
||||
_check_name(name)
|
||||
data = content.encode("utf-8")
|
||||
if len(data) > MAX_SECRET_BYTES:
|
||||
raise SecretError("content exceeds 1 MiB limit")
|
||||
directory = kind_dir(stack_id, kind, override)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
path = _path(stack_id, kind, name, override)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
os.chmod(path, 0o600)
|
||||
return {"name": name, "kind": kind, "size": len(data)}
|
||||
|
||||
|
||||
def delete_secret(stack_id: str, kind: str, name: str, override: Optional[str] = None) -> None:
|
||||
path = _path(stack_id, kind, name, override)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def exists(stack_id: str, kind: str, name: str, override: Optional[str] = None) -> bool:
|
||||
return os.path.isfile(_path(stack_id, kind, name, override))
|
||||
|
||||
|
||||
def attach(stack_id: str, kind: str, name: str, service: str,
|
||||
target: Optional[str] = None, override: Optional[str] = None) -> str:
|
||||
"""Reference a stored secret/config from ``service`` in the stack's compose
|
||||
file (relative ``file:`` path) and persist it. Returns the new YAML."""
|
||||
_check_kind(kind)
|
||||
_check_name(name)
|
||||
yaml_str = compose_service.read_compose(stack_id, override)
|
||||
file_path = rel_path(kind, name)
|
||||
if kind == "config":
|
||||
new_yaml = compose_edit_service.add_config(yaml_str, service, name, file_path, target or f"/{name}")
|
||||
else:
|
||||
new_yaml = compose_edit_service.add_secret(yaml_str, service, name, file_path)
|
||||
compose_service.write_compose(stack_id, new_yaml, override)
|
||||
return new_yaml
|
||||
|
||||
|
||||
def detach(stack_id: str, kind: str, name: str, service: str, override: Optional[str] = None) -> str:
|
||||
_check_kind(kind)
|
||||
yaml_str = compose_service.read_compose(stack_id, override)
|
||||
if kind == "config":
|
||||
new_yaml = compose_edit_service.remove_config(yaml_str, service, name)
|
||||
else:
|
||||
new_yaml = compose_edit_service.remove_secret(yaml_str, service, name)
|
||||
compose_service.write_compose(stack_id, new_yaml, override)
|
||||
return new_yaml
|
||||
Reference in New Issue
Block a user