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
+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