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
+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}