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