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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user