Phase 6: remote backup destinations — SFTP & S3 (0.6.0)

- BackupDestination model + backup_destination_service (SFTP via paramiko,
  S3-compatible via boto3): upload/list/download/delete/test.
- routers/destinations.py: destinations CRUD (secrets masked, merge-on-update),
  test, list/delete remote backups. backups.py: POST /{id}/backup/push and
  POST /restore-from (download from a destination + restore, volumes included).
- Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog
  can push to a destination; Restore dialog can pick a destination + backup.
- deps: paramiko 3.5.0, boto3 1.35.99.

Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations,
push (incl. volumes), list, restore-from to a fresh stack (volume data intact),
delete remote backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 21:50:03 +00:00
co-authored by Claude Opus 4.8
parent 59037f4287
commit 7bd449101d
12 changed files with 999 additions and 42 deletions
+151 -31
View File
@@ -1,10 +1,14 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui";
import { backupsApi } from "@/api/backups";
import { backupsApi, destinationsApi } from "@/api/backups";
import { apiErrorMessage } from "@/api/client";
import { formatBytes } from "@/lib/utils";
const selectClass =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
function Checkbox({
checked,
@@ -50,14 +54,30 @@ export function BackupButton({ stackId }: { stackId: string }) {
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);
const destinations = useQuery({
queryKey: ["destinations"],
queryFn: destinationsApi.list,
enabled: open,
});
const run = async () => {
setBusy(true);
const tid = toast.loading("Creating backup…");
try {
await backupsApi.download(stackId, { includeVolumes, stopFirst });
toast.success("Backup downloaded", { id: tid });
if (target === "download") {
await backupsApi.download(stackId, { includeVolumes, stopFirst });
toast.success("Backup downloaded", { id: tid });
} else {
const res = await backupsApi.push(stackId, {
destination_id: Number(target),
include_volumes: includeVolumes,
stop_first: stopFirst,
});
toast.success(`Backup pushed to ${res.destination}`, { id: tid });
}
setOpen(false);
} catch (e) {
toast.error(apiErrorMessage(e), { id: tid });
@@ -75,6 +95,17 @@ export function BackupButton({ stackId }: { stackId: string }) {
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Back up {stackId}</h2>
<div className="space-y-3">
<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)}>
<option value="download">Download to browser</option>
{destinations.data?.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.name} ({d.type})
</option>
))}
</select>
</label>
<Checkbox
checked={includeVolumes}
onChange={setIncludeVolumes}
@@ -93,7 +124,7 @@ export function BackupButton({ stackId }: { stackId: string }) {
Cancel
</Button>
<Button onClick={run} loading={busy}>
Download backup
{target === "download" ? "Download backup" : "Push backup"}
</Button>
</div>
</Modal>
@@ -105,33 +136,62 @@ export function BackupButton({ stackId }: { stackId: string }) {
export function RestoreButton() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"upload" | "destination">("upload");
const [file, setFile] = useState<File | null>(null);
const [destId, setDestId] = useState<string>("");
const [remoteName, setRemoteName] = useState<string>("");
const [targetId, setTargetId] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [restoreVolumes, setRestoreVolumes] = useState(true);
const [busy, setBusy] = useState(false);
const destinations = useQuery({
queryKey: ["destinations"],
queryFn: destinationsApi.list,
enabled: open,
});
const remoteBackups = useQuery({
queryKey: ["dest-backups", destId],
queryFn: () => destinationsApi.backups(Number(destId)),
enabled: open && mode === "destination" && !!destId,
});
const run = async () => {
if (!file) {
toast.error("Select a backup file");
return;
}
setBusy(true);
const tid = toast.loading("Restoring…");
try {
const res = await backupsApi.restore(file, {
targetId: targetId.trim() || undefined,
overwrite,
restoreVolumes,
});
toast.success(
`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`,
{ id: tid }
);
let res;
if (mode === "upload") {
if (!file) {
toast.error("Select a backup file", { id: tid });
setBusy(false);
return;
}
res = await backupsApi.restore(file, {
targetId: targetId.trim() || undefined,
overwrite,
restoreVolumes,
});
} else {
if (!destId || !remoteName) {
toast.error("Pick a destination and a backup", { id: tid });
setBusy(false);
return;
}
res = await backupsApi.restoreFrom({
destination_id: Number(destId),
name: remoteName,
target_id: targetId.trim() || undefined,
overwrite,
restore_volumes: restoreVolumes,
});
}
toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid });
qc.invalidateQueries({ queryKey: ["stacks"] });
setOpen(false);
setFile(null);
setTargetId("");
setRemoteName("");
} catch (e) {
toast.error(apiErrorMessage(e), { id: tid });
} finally {
@@ -147,13 +207,73 @@ export function RestoreButton() {
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
{(["upload", "destination"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={
mode === m
? "flex-1 rounded-md bg-card px-3 py-1.5 font-medium shadow-sm dark:bg-card-dark"
: "flex-1 rounded-md px-3 py-1.5 text-slate-500"
}
>
{m === "upload" ? "Upload file" : "From destination"}
</button>
))}
</div>
<div className="space-y-3">
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
/>
{mode === "upload" ? (
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
/>
) : (
<>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Destination</span>
<select
className={selectClass}
value={destId}
onChange={(e) => {
setDestId(e.target.value);
setRemoteName("");
}}
>
<option value="">Select</option>
{destinations.data?.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.name} ({d.type})
</option>
))}
</select>
</label>
{destId && (
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">
Backup {remoteBackups.isFetching && "(loading…)"}
</span>
<select
className={selectClass}
value={remoteName}
onChange={(e) => setRemoteName(e.target.value)}
>
<option value="">Select</option>
{remoteBackups.data?.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {formatBytes(b.size)}
</option>
))}
</select>
</label>
)}
</>
)}
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">
Restore as (optional leave blank to use the original name)
@@ -162,14 +282,10 @@ export function RestoreButton() {
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder="new-stack-name"
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
className={selectClass}
/>
</label>
<Checkbox
checked={restoreVolumes}
onChange={setRestoreVolumes}
label="Restore volume data"
/>
<Checkbox checked={restoreVolumes} onChange={setRestoreVolumes} label="Restore volume data" />
<Checkbox
checked={overwrite}
onChange={setOverwrite}
@@ -181,7 +297,11 @@ export function RestoreButton() {
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={run} loading={busy} disabled={!file}>
<Button
onClick={run}
loading={busy}
disabled={mode === "upload" ? !file : !destId || !remoteName}
>
Restore
</Button>
</div>