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