feat(snapshots): capture app data-volume snapshots
Build / build (push) Successful in 10m59s

Add per-workload capture of host-bind data volumes as downloadable tar.gz archives: a new internal/volsnap engine (enumerate host-bind volumes via the computeMounts merge, archive with archive/tar+gzip skipping symlinks/special files, per-workload retention + startup orphan cleanup), a volume_snapshots table + store CRUD, admin-gated API (list/snapshotable/create/download/delete), and a Snapshots panel on /apps/[id] that shows coverage and which volumes are skipped (and why). Scope: image-source apps, host-bind scopes (absolute/stage/project); Docker named volumes, tmpfs, and instance scope are surfaced as not-yet-supported. Restore is a separate later phase. Download/FilePath are containment-checked; create returns a typed no-data error (400) vs generic 500. Covered by archiver unit tests + full API e2e.
This commit is contained in:
2026-06-02 14:56:10 +03:00
parent 2ba49b9bb6
commit 6b45ed62bb
16 changed files with 1565 additions and 4 deletions
+51
View File
@@ -578,6 +578,57 @@ export function backupDownloadUrl(id: string): string {
return `/api/backups/${id}/download`;
}
// ── Volume Snapshots ───────────────────────────────────────────────
// Per-workload archives of host-bind data volumes. Capture-only for now
// (create/list/delete/download); restore is a separate later phase.
export interface SnapshotInfo {
id: string;
workload_id: string;
label: string;
filename: string;
size_bytes: number;
manifest: string; // JSON-encoded [{ index, target, scope, source }]
created_at: string;
}
export interface SnapshotableVolume {
target: string;
scope: string;
source: string;
}
export interface SkippedVolume {
target: string;
scope: string;
reason: string;
}
export interface SnapshotableInfo {
volumes: SnapshotableVolume[];
skipped: SkippedVolume[];
}
export function listWorkloadSnapshots(workloadId: string, signal?: AbortSignal): Promise<SnapshotInfo[]> {
return get<SnapshotInfo[]>(`/api/workloads/${workloadId}/snapshots`, signal);
}
export function getWorkloadSnapshotable(workloadId: string, signal?: AbortSignal): Promise<SnapshotableInfo> {
return get<SnapshotableInfo>(`/api/workloads/${workloadId}/snapshotable`, signal);
}
export function createWorkloadSnapshot(workloadId: string, label?: string): Promise<SnapshotInfo> {
return post<SnapshotInfo>(`/api/workloads/${workloadId}/snapshots`, label ? { label } : {});
}
export function deleteSnapshot(sid: string): Promise<void> {
return del<void>(`/api/snapshots/${sid}`);
}
export function snapshotDownloadUrl(sid: string): string {
return `/api/snapshots/${sid}/download`;
}
// ── Health ──────────────────────────────────────────────────────────
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {