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
+38
View File
@@ -0,0 +1,38 @@
import api from "./client";
export type SecretKind = "secret" | "config";
export interface SecretEntry {
name: string;
kind: SecretKind;
size: number;
modified: number;
}
// Local host, or a remote agent's stack when agentId is given.
const base = (stackId: string, agentId?: number) =>
agentId != null
? `/api/agents/${agentId}/stacks/${stackId}/secrets`
: `/api/stacks/${stackId}/secrets`;
export const secretsApi = {
list: (stackId: string, agentId?: number) =>
api.get<SecretEntry[]>(base(stackId, agentId)).then((r) => r.data),
write: (
stackId: string,
body: { kind: SecretKind; name: string; content: string },
agentId?: number,
) => api.put(base(stackId, agentId), body).then((r) => r.data),
remove: (stackId: string, kind: SecretKind, name: string, agentId?: number) =>
api.delete(`${base(stackId, agentId)}/${kind}/${name}`).then((r) => r.data),
attach: (
stackId: string,
body: { kind: SecretKind; name: string; service: string; target?: string },
agentId?: number,
) => api.post(`${base(stackId, agentId)}/attach`, body).then((r) => r.data),
detach: (
stackId: string,
body: { kind: SecretKind; name: string; service: string },
agentId?: number,
) => api.post(`${base(stackId, agentId)}/detach`, body).then((r) => r.data),
};
@@ -0,0 +1,222 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Trash2, Link2, FileCog } from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input } from "@/components/ui";
import { secretsApi, type SecretEntry, type SecretKind } from "@/api/secrets";
import { editorApi } from "@/api/editor";
import { apiErrorMessage } from "@/api/client";
/**
* Per-stack file-based secrets & configs. Create stores a file in the stack
* dir; attach references it from a service in the compose file (redeploy to
* apply). Admin-only on the backend.
*/
export function SecretsPanel({
stackId,
yaml,
agentId,
isAdmin,
onChanged,
}: {
stackId: string;
yaml: string;
agentId?: number;
isAdmin: boolean;
onChanged?: () => void;
}) {
const qc = useQueryClient();
const key = ["secrets", agentId ?? "local", stackId];
const list = useQuery({
queryKey: key,
queryFn: () => secretsApi.list(stackId, agentId),
enabled: isAdmin,
});
const services = useQuery({
queryKey: ["editor-services", stackId, agentId, yaml.length],
queryFn: () => editorApi.services(yaml),
enabled: isAdmin,
});
const [kind, setKind] = useState<SecretKind>("secret");
const [name, setName] = useState("");
const [content, setContent] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: key });
const create = useMutation({
mutationFn: () => secretsApi.write(stackId, { kind, name: name.trim(), content }, agentId),
onSuccess: () => {
toast.success(`${kind} "${name}" saved`);
setName(""); setContent("");
invalidate();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: (s: SecretEntry) => secretsApi.remove(stackId, s.kind, s.name, agentId),
onSuccess: () => { toast.success("Deleted"); invalidate(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const attach = useMutation({
mutationFn: (v: { s: SecretEntry; service: string; target?: string }) =>
secretsApi.attach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service, target: v.target }, agentId),
onSuccess: () => { toast.success("Attached — redeploy the stack to apply"); onChanged?.(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const detach = useMutation({
mutationFn: (v: { s: SecretEntry; service: string }) =>
secretsApi.detach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service }, agentId),
onSuccess: () => { toast.success("Detached — redeploy the stack to apply"); onChanged?.(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
if (!isAdmin)
return (
<Card>
<p className="text-sm text-slate-500">Secrets are visible to admins only.</p>
</Card>
);
const items = list.data ?? [];
const svcList = services.data ?? [];
return (
<div className="space-y-4 overflow-auto">
<Card className="space-y-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-accent dark:text-accent-dark" />
<span className="font-medium">New secret / config</span>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-sm">
<span className="mb-1 block text-slate-500">Type</span>
<select
value={kind}
onChange={(e) => setKind(e.target.value as SecretKind)}
className="rounded-md border border-slate-300 bg-transparent px-2 py-2 text-sm dark:border-slate-600"
>
<option value="secret">Secret (/run/secrets)</option>
<option value="config">Config (mounted file)</option>
</select>
</label>
<label className="text-sm">
<span className="mb-1 block text-slate-500">Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="db_password" />
</label>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
placeholder="secret content…"
className="w-full rounded-md border border-slate-300 bg-transparent p-2 font-mono text-sm dark:border-slate-600"
/>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!name.trim() || !content}
>
Save
</Button>
</Card>
<Card className="p-0">
{items.length === 0 ? (
<p className="p-4 text-sm text-slate-500">No secrets or configs yet.</p>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-700">
{items.map((s) => (
<SecretRow
key={`${s.kind}/${s.name}`}
s={s}
services={svcList}
onDelete={() => remove.mutate(s)}
onAttach={(service, target) => attach.mutate({ s, service, target })}
onDetach={(service) => detach.mutate({ s, service })}
/>
))}
</ul>
)}
</Card>
</div>
);
}
function SecretRow({
s,
services,
onDelete,
onAttach,
onDetach,
}: {
s: SecretEntry;
services: string[];
onDelete: () => void;
onAttach: (service: string, target?: string) => void;
onDetach: (service: string) => void;
}) {
const [service, setService] = useState(services[0] ?? "");
const [target, setTarget] = useState("");
return (
<li className="flex flex-wrap items-center gap-3 p-3">
{s.kind === "config" ? (
<FileCog className="h-4 w-4 shrink-0 text-slate-400" />
) : (
<KeyRound className="h-4 w-4 shrink-0 text-slate-400" />
)}
<div className="min-w-0">
<span className="font-mono">{s.name}</span>
<Badge>{s.kind}</Badge>
</div>
<span className="text-xs text-slate-500">{s.size} B</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<select
value={service}
onChange={(e) => setService(e.target.value)}
className="rounded-md border border-slate-300 bg-transparent px-2 py-1 text-sm dark:border-slate-600"
>
{services.length === 0 && <option value="">no services</option>}
{services.map((sv) => (
<option key={sv} value={sv}>{sv}</option>
))}
</select>
{s.kind === "config" && (
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="/etc/app.conf"
className="w-40"
/>
)}
<Button
variant="outline"
className="px-2 py-1"
disabled={!service || (s.kind === "config" && !target)}
onClick={() => onAttach(service, target || undefined)}
title="Attach to service"
>
<Link2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="px-2 py-1"
disabled={!service}
onClick={() => onDetach(service)}
title="Detach from service"
>
detach
</Button>
<Button variant="outline" className="px-2 py-1" onClick={onDelete} title="Delete file">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</li>
);
}
+11 -1
View File
@@ -17,13 +17,14 @@ import { HostDot } from "@/components/hosts/HostDot";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
type Tab = (typeof TABS)[number];
export function RemoteStackDetail() {
@@ -166,6 +167,15 @@ export function RemoteStackDetail() {
queryKey={["agent-stack", aid, id]}
/>
)}
{tab === "Secrets" && (
<SecretsPanel
stackId={id}
yaml={data.yaml}
agentId={aid}
isAdmin={isAdmin}
onChanged={() => qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })}
/>
)}
</div>
</div>
);
+10 -1
View File
@@ -17,6 +17,7 @@ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
@@ -24,7 +25,7 @@ import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
type Tab = (typeof TABS)[number];
export function StackDetail() {
@@ -115,6 +116,14 @@ export function StackDetail() {
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
{tab === "Secrets" && (
<SecretsPanel
stackId={id}
yaml={data.yaml}
isAdmin={isAdmin}
onChanged={() => queryClient.invalidateQueries({ queryKey: ["stack", id] })}
/>
)}
</div>
</div>
);