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
+74 -1
View File
@@ -39,6 +39,7 @@ from config import settings
from docker_client import DockerError, get_client, safe_call
from services import (
backup_service,
compose_edit_service,
compose_service,
container_service,
device_service,
@@ -46,6 +47,7 @@ from services import (
file_service,
image_service,
network_service,
secret_service,
stats_service,
update_service,
volume_service,
@@ -64,7 +66,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.28.0"
AGENT_VERSION = "0.29.0"
# --------------------------------------------------------------------------- #
@@ -375,6 +377,77 @@ async def stack_updates(stack_id: str, refresh: bool = Query(True)) -> dict:
return await update_service.stack_updates(stack_id, refresh=refresh)
# --------------------------------------------------------------------------- #
# Secrets & configs (per-stack, file-based)
# --------------------------------------------------------------------------- #
class SecretWriteBody(BaseModel):
kind: str = "secret"
name: str
content: str
class SecretAttachBody(BaseModel):
kind: str = "secret"
name: str
service: str
target: str | None = None
class SecretDetachBody(BaseModel):
kind: str = "secret"
name: str
service: str
def _ensure_stack(stack_id: str) -> None:
if not os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
def _secret_guard(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except (secret_service.SecretError, compose_edit_service.EditError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/agent/stacks/{stack_id}/secrets", dependencies=[Depends(verify_token)])
def agent_list_secrets(stack_id: str) -> list:
_ensure_stack(stack_id)
return secret_service.list_all(stack_id)
@app.put("/agent/stacks/{stack_id}/secrets", dependencies=[Depends(verify_token)])
def agent_write_secret(stack_id: str, body: SecretWriteBody) -> dict:
_ensure_stack(stack_id)
return _secret_guard(secret_service.write_secret, stack_id, body.kind, body.name, body.content)
@app.delete("/agent/stacks/{stack_id}/secrets/{kind}/{name}", dependencies=[Depends(verify_token)])
def agent_delete_secret(stack_id: str, kind: str, name: str) -> dict:
_ensure_stack(stack_id)
_secret_guard(secret_service.delete_secret, stack_id, kind, name)
return {"ok": True}
@app.post("/agent/stacks/{stack_id}/secrets/attach", dependencies=[Depends(verify_token)])
def agent_attach_secret(stack_id: str, body: SecretAttachBody) -> dict:
_ensure_stack(stack_id)
if not secret_service.exists(stack_id, body.kind, body.name):
raise HTTPException(status_code=404, detail="secret not found")
new_yaml = _secret_guard(secret_service.attach, stack_id, body.kind, body.name, body.service, body.target)
return {"ok": True, "yaml": new_yaml}
@app.post("/agent/stacks/{stack_id}/secrets/detach", dependencies=[Depends(verify_token)])
def agent_detach_secret(stack_id: str, body: SecretDetachBody) -> dict:
_ensure_stack(stack_id)
new_yaml = _secret_guard(secret_service.detach, stack_id, body.kind, body.name, body.service)
return {"ok": True, "yaml": new_yaml}
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
async def backup_stack(
stack_id: str,
+3 -1
View File
@@ -26,6 +26,7 @@ from routers import (
networks,
ports,
schedules,
secrets,
settings as settings_router,
stacks,
system,
@@ -56,7 +57,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.28.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.29.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -77,6 +78,7 @@ async def docker_error_handler(_request: Request, exc: DockerError):
app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(secrets.router)
app.include_router(containers.router)
app.include_router(system.router)
app.include_router(volumes.router)
+90
View File
@@ -19,6 +19,7 @@ from models.stack import StackCreate, StackUpdate
from models.user import User
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
from routers.networks import ContainerRef, NetworkCreate
from routers.secrets import AttachBody, DetachBody, SecretWrite
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
from services import (
agent_service,
@@ -1039,3 +1040,92 @@ async def agent_run_auto_update(
await auto_update_service.run_policy(session, policy)
session.refresh(policy)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
# --------------------------------------------------------------------------- #
# Secrets & configs (proxied) — remote stacks
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/stacks/{stack_id}/secrets")
async def agent_list_secrets(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/secrets") or []
@router.put("/{agent_id}/stacks/{stack_id}/secrets")
async def agent_write_secret(
agent_id: int,
stack_id: str,
body: SecretWrite,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "PUT", f"/agent/stacks/{stack_id}/secrets", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.secret.write",
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}", ip=_ip(request),
)
return result
@router.delete("/{agent_id}/stacks/{stack_id}/secrets/{kind}/{name}")
async def agent_delete_secret(
agent_id: int,
stack_id: str,
kind: str,
name: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "DELETE", f"/agent/stacks/{stack_id}/secrets/{kind}/{name}")
audit_service.record(
session, user=user.username, action="agent.secret.delete",
target=f"{agent.name}/{stack_id}/{kind}/{name}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/stacks/{stack_id}/secrets/attach")
async def agent_attach_secret(
agent_id: int,
stack_id: str,
body: AttachBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/secrets/attach", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.secret.attach",
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/stacks/{stack_id}/secrets/detach")
async def agent_detach_secret(
agent_id: int,
stack_id: str,
body: DetachBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/secrets/detach", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.secret.detach",
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
)
return result
+139
View File
@@ -0,0 +1,139 @@
"""Per-stack file-based secrets & configs (compose) — local host.
CRUD over the secret/config files stored in the stack directory, plus
attach/detach which rewrite the stack's compose file to reference (or stop
referencing) a secret/config from a service. A redeploy is required for changes
to take effect on running containers.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlmodel import Session
from auth import require_admin
from database import get_session
from models.user import User
from services import audit_service, secret_service
from services.compose_edit_service import EditError
from services.secret_service import SecretError
router = APIRouter(prefix="/api/stacks/{stack_id}/secrets", tags=["secrets"])
class SecretWrite(BaseModel):
kind: str = "secret" # "secret" | "config"
name: str
content: str
class AttachBody(BaseModel):
kind: str = "secret"
name: str
service: str
target: str | None = None # required for configs (mount path)
class DetachBody(BaseModel):
kind: str = "secret"
name: str
service: str
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _guard(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except (SecretError, EditError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# --- These functions are shared verbatim by the agent (see agent_app.py). ---
def list_secrets(stack_id: str) -> list[dict]:
return secret_service.list_all(stack_id)
def write_secret(stack_id: str, body: SecretWrite) -> dict:
return _guard(secret_service.write_secret, stack_id, body.kind, body.name, body.content)
def delete_secret(stack_id: str, kind: str, name: str) -> dict:
_guard(secret_service.delete_secret, stack_id, kind, name)
return {"ok": True}
def attach_secret(stack_id: str, body: AttachBody) -> dict:
if not secret_service.exists(stack_id, body.kind, body.name):
raise HTTPException(status_code=404, detail="secret not found")
new_yaml = _guard(secret_service.attach, stack_id, body.kind, body.name, body.service, body.target)
return {"ok": True, "yaml": new_yaml}
def detach_secret(stack_id: str, body: DetachBody) -> dict:
new_yaml = _guard(secret_service.detach, stack_id, body.kind, body.name, body.service)
return {"ok": True, "yaml": new_yaml}
# --- HTTP endpoints (admin only; secrets are sensitive) ---
@router.get("")
def http_list(stack_id: str, _user: User = Depends(require_admin)) -> list[dict]:
return list_secrets(stack_id)
@router.put("")
def http_write(
stack_id: str, body: SecretWrite, request: Request,
session: Session = Depends(get_session), user: User = Depends(require_admin),
) -> dict:
result = write_secret(stack_id, body)
audit_service.record(
session, user=user.username, action="secret.write",
target=f"{stack_id}/{body.kind}/{body.name}", ip=_ip(request),
)
return result
@router.delete("/{kind}/{name}")
def http_delete(
stack_id: str, kind: str, name: str, request: Request,
session: Session = Depends(get_session), user: User = Depends(require_admin),
) -> dict:
result = delete_secret(stack_id, kind, name)
audit_service.record(
session, user=user.username, action="secret.delete",
target=f"{stack_id}/{kind}/{name}", ip=_ip(request),
)
return result
@router.post("/attach")
def http_attach(
stack_id: str, body: AttachBody, request: Request,
session: Session = Depends(get_session), user: User = Depends(require_admin),
) -> dict:
result = attach_secret(stack_id, body)
audit_service.record(
session, user=user.username, action="secret.attach",
target=f"{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
)
return result
@router.post("/detach")
def http_detach(
stack_id: str, body: DetachBody, request: Request,
session: Session = Depends(get_session), user: User = Depends(require_admin),
) -> dict:
result = detach_secret(stack_id, body)
audit_service.record(
session, user=user.username, action="secret.detach",
target=f"{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
)
return result
+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)
+128
View File
@@ -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