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
+60
View File
@@ -1,5 +1,19 @@
import api from "./client";
export interface BackupDestination {
id: number;
name: string;
type: "sftp" | "s3";
config: Record<string, string>;
created_at: string;
}
export interface RemoteBackup {
name: string;
size: number;
modified: number | null;
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -42,4 +56,50 @@ export const backupsApi = {
}>("/api/stacks/restore", form);
return res.data;
},
push: (
stackId: string,
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
) =>
api
.post<{ ok: boolean; destination: string; name: string }>(
`/api/stacks/${stackId}/backup/push`,
body
)
.then((r) => r.data),
restoreFrom: (body: {
destination_id: number;
name: string;
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),
};
export const destinationsApi = {
list: () =>
api.get<BackupDestination[]>("/api/backups/destinations").then((r) => r.data),
create: (body: { name: string; type: string; config: Record<string, string> }) =>
api.post<BackupDestination>("/api/backups/destinations", body).then((r) => r.data),
update: (id: number, body: { name?: string; config?: Record<string, string> }) =>
api.put<BackupDestination>(`/api/backups/destinations/${id}`, body).then((r) => r.data),
remove: (id: number) =>
api.delete(`/api/backups/destinations/${id}`).then((r) => r.data),
test: (id: number) =>
api
.post<{ ok: boolean; error?: string }>(`/api/backups/destinations/${id}/test`)
.then((r) => r.data),
backups: (id: number) =>
api.get<RemoteBackup[]>(`/api/backups/destinations/${id}/backups`).then((r) => r.data),
deleteBackup: (id: number, name: string) =>
api
.delete(`/api/backups/destinations/${id}/backups/${encodeURIComponent(name)}`)
.then((r) => r.data),
};