Back up bind-mount data, not just the compose file (0.40.0)

A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.

New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.

- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
  restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
  back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
  gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
  take include_binds/binds/volumes, restore takes restore_binds

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-08-16 18:20:19 +00:00
co-authored by Claude Opus 5
parent ecf780c5e6
commit 5347a36eaf
11 changed files with 1301 additions and 217 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.39.0",
"version": "0.40.0",
"type": "module",
"scripts": {
"dev": "vite",
+27 -16
View File
@@ -1,4 +1,6 @@
import api from "./client";
import { backupParams, readReport } from "./backups";
import type { BackupInventory, BackupOptions, RestoreResult } from "./backups";
import type { Agent, StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types";
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
@@ -54,13 +56,13 @@ export const agentsApi = {
action: (id: number, stackId: string, action: string) =>
api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data),
backupDownload: async (
id: number,
stackId: string,
opts: { includeVolumes: boolean; stopFirst: boolean }
) => {
backupInventory: (id: number, stackId: string) =>
api
.get<BackupInventory>(`/api/agents/${id}/stacks/${stackId}/backup/inventory`)
.then((r) => r.data),
backupDownload: async (id: number, stackId: string, opts: BackupOptions) => {
const res = await api.get(`/api/agents/${id}/stacks/${stackId}/backup`, {
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
params: backupParams(opts),
responseType: "blob",
});
const cd = res.headers["content-disposition"] as string | undefined;
@@ -73,11 +75,19 @@ export const agentsApi = {
a.click();
a.remove();
URL.revokeObjectURL(url);
return readReport(res.headers);
},
backupPush: (
id: number,
stackId: string,
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
body: {
destination_id: number;
include_volumes: boolean;
include_binds?: boolean;
stop_first: boolean;
binds?: string[];
volumes?: string[];
}
) =>
api
.post<{ ok: boolean; destination: string; name: string }>(
@@ -88,18 +98,21 @@ export const agentsApi = {
restoreUpload: (
id: number,
file: File,
opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean }
opts: {
targetId?: string;
overwrite: boolean;
restoreVolumes: boolean;
restoreBinds?: boolean;
}
) => {
const form = new FormData();
form.append("file", file);
if (opts.targetId) form.append("target_id", opts.targetId);
form.append("overwrite", String(opts.overwrite));
form.append("restore_volumes", String(opts.restoreVolumes));
form.append("restore_binds", String(opts.restoreBinds ?? true));
return api
.post<{ stack_id: string; name: string; volumes_restored: number }>(
`/api/agents/${id}/stacks/restore`,
form
)
.post<RestoreResult>(`/api/agents/${id}/stacks/restore`, form)
.then((r) => r.data);
},
restoreFrom: (
@@ -110,12 +123,10 @@ export const agentsApi = {
target_id?: string;
overwrite: boolean;
restore_volumes: boolean;
restore_binds?: boolean;
}
) =>
api
.post<{ stack_id: string; name: string; volumes_restored: number }>(
`/api/agents/${id}/stacks/restore-from`,
body
)
.post<RestoreResult>(`/api/agents/${id}/stacks/restore-from`, body)
.then((r) => r.data),
};
+113 -23
View File
@@ -8,6 +8,87 @@ export interface BackupDestination {
created_at: string;
}
/** One bind-mount source or named volume a backup would capture. */
export interface BackupBind {
source: string;
mounts: { service: string; target: string }[];
kind: string;
size: number | null;
inside_stack_dir: boolean;
covered_by_compose: boolean;
via: "compose" | "archive";
system: boolean;
include_default: boolean;
selected: boolean;
reason: string | null;
}
export interface BackupVolume {
name: string;
short: string;
driver: string;
remote: boolean;
remote_type: string | null;
include_default: boolean;
selected: boolean;
reason: string | null;
}
export interface BackupInventory {
stack_id: string;
stack_dir: string;
stack_dir_visible: boolean;
path_mismatch: { host: string; container: string } | null;
binds: BackupBind[];
volumes: BackupVolume[];
}
export interface BackupReport {
size: number | null;
binds: { source: string; bytes: number | null; error: string | null }[];
volumes: { name: string; bytes: number | null; error: string | null }[];
skipped: { kind: string; source?: string; name?: string; reason: string | null }[];
path_mismatch: { host: string; container: string } | null;
}
export interface RestoreResult {
stack_id: string;
name: string;
volumes_restored: number;
binds_restored?: number;
skipped?: { kind: string; source?: string; name?: string; reason: string | null }[];
}
export interface BackupOptions {
includeVolumes: boolean;
stopFirst: boolean;
includeBinds?: boolean;
binds?: string[];
volumes?: string[];
}
/** Query params shared by the local and the agent-proxied backup endpoints. */
export function backupParams(opts: BackupOptions) {
return {
include_volumes: opts.includeVolumes,
include_binds: opts.includeBinds ?? true,
stop_first: opts.stopFirst,
...(opts.binds ? { binds: opts.binds } : {}),
...(opts.volumes ? { volumes: opts.volumes } : {}),
};
}
/** The backup summary the server attaches to the download response. */
export function readReport(headers: unknown): BackupReport | null {
const raw = (headers as Record<string, string> | undefined)?.["x-stackpilot-backup"];
if (!raw) return null;
try {
return JSON.parse(raw) as BackupReport;
} catch {
return null;
}
}
export interface RemoteBackup {
name: string;
size: number;
@@ -26,46 +107,60 @@ function triggerDownload(blob: Blob, filename: string) {
}
export const backupsApi = {
download: async (
stackId: string,
opts: { includeVolumes: boolean; stopFirst: boolean }
) => {
inventory: (stackId: string) =>
api
.get<BackupInventory>(`/api/stacks/${stackId}/backup/inventory`)
.then((r) => r.data),
download: async (stackId: string, opts: BackupOptions) => {
const res = await api.get(`/api/stacks/${stackId}/backup`, {
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
params: backupParams(opts),
responseType: "blob",
});
const cd = res.headers["content-disposition"] as string | undefined;
const match = cd?.match(/filename="?([^"]+)"?/);
const name = match?.[1] ?? `backup-${stackId}.tar.gz`;
triggerDownload(res.data as Blob, name);
return readReport(res.headers);
},
restore: async (
file: File,
opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean }
opts: {
targetId?: string;
overwrite: boolean;
restoreVolumes: boolean;
restoreBinds?: boolean;
}
) => {
const form = new FormData();
form.append("file", file);
if (opts.targetId) form.append("target_id", opts.targetId);
form.append("overwrite", String(opts.overwrite));
form.append("restore_volumes", String(opts.restoreVolumes));
const res = await api.post<{
stack_id: string;
name: string;
volumes_restored: number;
}>("/api/stacks/restore", form);
form.append("restore_binds", String(opts.restoreBinds ?? true));
const res = await api.post<RestoreResult>("/api/stacks/restore", form);
return res.data;
},
push: (
stackId: string,
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
body: {
destination_id: number;
include_volumes: boolean;
include_binds?: boolean;
stop_first: boolean;
binds?: string[];
volumes?: string[];
}
) =>
api
.post<{ ok: boolean; destination: string; name: string }>(
`/api/stacks/${stackId}/backup/push`,
body
)
.post<{
ok: boolean;
destination: string;
name: string;
report?: BackupReport;
}>(`/api/stacks/${stackId}/backup/push`, body)
.then((r) => r.data),
restoreFrom: (body: {
@@ -74,13 +169,8 @@ export const backupsApi = {
target_id?: string;
overwrite: boolean;
restore_volumes: boolean;
}) =>
api
.post<{ stack_id: string; name: string; volumes_restored: number }>(
"/api/stacks/restore-from",
body
)
.then((r) => r.data),
restore_binds?: boolean;
}) => api.post<RestoreResult>("/api/stacks/restore-from", body).then((r) => r.data),
};
export const destinationsApi = {
+199 -22
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Upload } from "lucide-react";
import { AlertTriangle, Archive, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui";
import { backupsApi, destinationsApi } from "@/api/backups";
import type { BackupReport } from "@/api/backups";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { formatBytes } from "@/lib/utils";
@@ -38,11 +39,21 @@ function Checkbox({
);
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
function Modal({
children,
onClose,
wide,
}: {
children: React.ReactNode;
onClose: () => void;
wide?: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
className={`max-h-[85vh] w-full overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark ${
wide ? "max-w-xl" : "max-w-md"
}`}
onClick={(e) => e.stopPropagation()}
>
{children}
@@ -51,6 +62,61 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
);
}
function AssetRow({
checked,
onChange,
title,
detail,
size,
badges,
disabled,
}: {
checked: boolean;
onChange: (v: boolean) => void;
title: string;
detail?: string;
size?: number | null;
badges?: { text: string; tone?: "muted" | "warn" }[];
disabled?: boolean;
}) {
return (
<label className="flex items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-800/60">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded border-slate-300 text-accent disabled:opacity-40"
/>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate font-mono text-xs text-slate-700 dark:text-slate-200">{title}</span>
<span className="shrink-0 text-[11px] tabular-nums text-slate-400">
{size != null ? formatBytes(size) : ""}
</span>
</span>
{detail && <span className="block truncate text-[11px] text-slate-500">{detail}</span>}
{badges && badges.length > 0 && (
<span className="mt-0.5 flex flex-wrap gap-1">
{badges.map((b) => (
<span
key={b.text}
className={`rounded-chip px-1.5 py-0.5 text-[10px] ${
b.tone === "warn"
? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
: "bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-300"
}`}
>
{b.text}
</span>
))}
</span>
)}
</span>
</label>
);
}
export function BackupButton({
stackId,
agentId,
@@ -59,39 +125,75 @@ export function BackupButton({
agentId?: number;
}) {
const [open, setOpen] = useState(false);
const [includeVolumes, setIncludeVolumes] = useState(true);
const [stopFirst, setStopFirst] = useState(true);
const [target, setTarget] = useState("download"); // "download" | destination id
const [busy, setBusy] = useState(false);
// null = "use the inventory defaults" (until the user touches a checkbox).
const [pickedBinds, setPickedBinds] = useState<string[] | null>(null);
const [pickedVolumes, setPickedVolumes] = useState<string[] | null>(null);
const destinations = useQuery({
queryKey: ["destinations"],
queryFn: destinationsApi.list,
enabled: open,
});
const inventory = useQuery({
queryKey: ["backup-inventory", agentId ?? "local", stackId],
queryFn: () =>
agentId != null
? agentsApi.backupInventory(agentId, stackId)
: backupsApi.inventory(stackId),
enabled: open,
});
const binds = inventory.data?.binds ?? [];
const volumes = inventory.data?.volumes ?? [];
const bindSel = pickedBinds ?? binds.filter((b) => b.include_default).map((b) => b.source);
const volSel = pickedVolumes ?? volumes.filter((v) => v.include_default).map((v) => v.name);
const toggle = (list: string[], value: string, on: boolean) =>
on ? [...list, value] : list.filter((v) => v !== value);
const describe = (report: BackupReport | null | undefined) => {
if (!report) return "Backup created";
const parts = [`${report.binds.length} folder(s)`, `${report.volumes.length} volume(s)`];
if (report.size) parts.push(formatBytes(report.size));
const skipped = report.skipped.filter((s) => s.reason !== "not requested").length;
return `Backup: ${parts.join(" · ")}${skipped ? `${skipped} skipped` : ""}`;
};
const run = async () => {
setBusy(true);
const tid = toast.loading("Creating backup…");
try {
const opts = {
includeVolumes: volSel.length > 0,
includeBinds: bindSel.length > 0,
stopFirst,
// Empty lists would be dropped from the query string and read as
// "use defaults", so the include_* flags carry that case.
binds: bindSel.length ? bindSel : undefined,
volumes: volSel.length ? volSel : undefined,
};
if (target === "download") {
if (agentId != null) {
await agentsApi.backupDownload(agentId, stackId, { includeVolumes, stopFirst });
} else {
await backupsApi.download(stackId, { includeVolumes, stopFirst });
}
toast.success("Backup downloaded", { id: tid });
const report =
agentId != null
? await agentsApi.backupDownload(agentId, stackId, opts)
: await backupsApi.download(stackId, opts);
toast.success(describe(report), { id: tid });
} else {
const body = {
destination_id: Number(target),
include_volumes: includeVolumes,
include_volumes: opts.includeVolumes,
include_binds: opts.includeBinds,
stop_first: stopFirst,
binds: opts.binds,
volumes: opts.volumes,
};
const res =
agentId != null
? await agentsApi.backupPush(agentId, stackId, body)
: await backupsApi.push(stackId, body);
toast.success(`Backup pushed to ${res.destination}`, { id: tid });
toast.success(`Pushed to ${res.destination}`, { id: tid });
}
setOpen(false);
} catch (e) {
@@ -107,9 +209,75 @@ export function BackupButton({
<Archive className="h-4 w-4" /> Backup
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<Modal wide onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 sp-heading text-lg">Back up {stackId}</h2>
<div className="space-y-3">
{inventory.data?.path_mismatch && (
<div className="flex gap-2 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
This stack's folder is <code>{inventory.data.path_mismatch.container}</code> inside
StackPilot but <code>{inventory.data.path_mismatch.host}</code> on the host, so the
containers' data directories are not visible here. They are read through a helper
container and are listed below.
</span>
</div>
)}
<div>
<div className="mb-1 text-xs font-medium text-slate-500">Contents</div>
<div className="rounded-lg border border-slate-200 p-1 dark:border-slate-700">
<AssetRow
checked
disabled
onChange={() => {}}
title={inventory.data?.stack_dir ?? "stack folder"}
detail="Compose file, .env and everything else in the stack folder"
badges={[{ text: "always included" }]}
/>
{inventory.isLoading && (
<div className="px-2 py-2 text-xs text-slate-500">Scanning stack data</div>
)}
{binds.map((b) => (
<AssetRow
key={b.source}
checked={bindSel.includes(b.source)}
disabled={b.system || b.kind === "special"}
onChange={(v) => setPickedBinds(toggle(bindSel, b.source, v))}
title={b.source}
detail={b.mounts.map((m) => `${m.service}:${m.target}`).join(", ")}
size={b.size}
badges={[
...(b.via === "compose" ? [{ text: "in stack folder" as const }] : []),
...(b.reason ? [{ text: b.reason, tone: "warn" as const }] : []),
]}
/>
))}
{volumes.map((v) => (
<AssetRow
key={v.name}
checked={volSel.includes(v.name)}
onChange={(on) => setPickedVolumes(toggle(volSel, v.name, on))}
title={v.name}
detail={`named volume (${v.driver})`}
badges={[
...(v.remote ? [{ text: v.remote_type || "remote", tone: "warn" as const }] : []),
...(v.reason && !v.remote ? [{ text: v.reason, tone: "warn" as const }] : []),
]}
/>
))}
{!inventory.isLoading && binds.length === 0 && volumes.length === 0 && (
<div className="px-2 py-2 text-xs text-slate-500">
No bind mounts or named volumes found for this stack.
</div>
)}
</div>
<p className="mt-1 text-[11px] text-slate-500">
Remote storage (NFS/CIFS) is unchecked by default it lives on your NAS and would be
overwritten on restore.
</p>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Destination</span>
<select className={selectClass} value={target} onChange={(e) => setTarget(e.target.value)}>
@@ -121,17 +289,11 @@ export function BackupButton({
))}
</select>
</label>
<Checkbox
checked={includeVolumes}
onChange={setIncludeVolumes}
label="Include named volume data"
hint="Snapshots each compose-managed volume into the archive."
/>
<Checkbox
checked={stopFirst}
onChange={setStopFirst}
label="Stop the stack during backup"
hint="Recommended for a consistent volume snapshot; the stack is restarted afterwards."
hint="Recommended for a consistent snapshot; the stack is restarted afterwards."
/>
</div>
<div className="mt-4 flex justify-end gap-2">
@@ -158,6 +320,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
const [targetId, setTargetId] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [restoreVolumes, setRestoreVolumes] = useState(true);
const [restoreBinds, setRestoreBinds] = useState(true);
const [busy, setBusy] = useState(false);
const destinations = useQuery({
@@ -182,7 +345,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
setBusy(false);
return;
}
const opts = { targetId: targetId.trim() || undefined, overwrite, restoreVolumes };
const opts = {
targetId: targetId.trim() || undefined,
overwrite,
restoreVolumes,
restoreBinds,
};
res =
agentId != null
? await agentsApi.restoreUpload(agentId, file, opts)
@@ -199,13 +367,16 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
target_id: targetId.trim() || undefined,
overwrite,
restore_volumes: restoreVolumes,
restore_binds: restoreBinds,
};
res =
agentId != null
? await agentsApi.restoreFrom(agentId, body)
: await backupsApi.restoreFrom(body);
}
toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid });
const bits = [`${res.volumes_restored} volume(s)`];
if (res.binds_restored) bits.push(`${res.binds_restored} folder(s)`);
toast.success(`Restored '${res.stack_id}' — ${bits.join(", ")}`, { id: tid });
qc.invalidateQueries({ queryKey: agentId != null ? ["agent-stacks", agentId] : ["stacks"] });
setOpen(false);
setFile(null);
@@ -305,6 +476,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
/>
</label>
<Checkbox checked={restoreVolumes} onChange={setRestoreVolumes} label="Restore volume data" />
<Checkbox
checked={restoreBinds}
onChange={setRestoreBinds}
label="Restore bind-mounted folders"
hint="Writes the captured config folders back to their host paths."
/>
<Checkbox
checked={overwrite}
onChange={setOverwrite}