feat(volsnap): volume snapshot restore (backlog #6)

Restore a captured volume snapshot onto an image workload's live host-bind
data volumes, then redeploy — the most destructive workload action, built to
the adversarially-reviewed design (C1–C6) with all data-loss guards.

- Engine.Restore (engine-owned): all-or-nothing pre-flight re-resolution from
  the workload's CURRENT config (never the tamperable manifest), per-filesystem
  disk pre-check, per-workload lock, container quiesce, extract-to-tmp, durable
  pre-restore snapshot, write-ahead journal, atomic rename swap, redeploy, and
  crash-recovery sweep (RecoverInterruptedRestores) wired before serving.
- internal/keyedmutex: shared per-key lock; deployer now serializes every
  deploy entrypoint per workload via DispatchPlugin (+ LockWorkload/RedeployLocked
  for the restore re-dispatch, no deadlock).
- Untrusted-archive extractor: zip-slip containment, type allow-list (reg/dir
  only), decompression-bomb cap, manifest-index bounds.
- POST /api/workloads/{id}/snapshots/{sid}/restore: admin, X-Confirm-Restore
  header (CSRF), per-workload single-flight (409).
- WebUI: Restore button + danger ConfirmDialog + busy state + i18n (en/ru).

Scope: image-source only; scopes absolute/stage/project (driven off the same
supportedScopes constant capture uses).

Plan-reviewed before coding; per-phase go/security/ts reviews; final review
READY TO MERGE. Security review caught + fixed a CRITICAL manifest-Source path
traversal (re-derive target from current config + base containment).

Plan: plans/volume-snapshot-restore/
This commit is contained in:
2026-06-22 17:23:52 +03:00
parent 8a5f69af87
commit 1c47030854
33 changed files with 2825 additions and 34 deletions
+16 -2
View File
@@ -579,8 +579,8 @@ export function backupDownloadUrl(id: string): string {
}
// ── Volume Snapshots ───────────────────────────────────────────────
// Per-workload archives of host-bind data volumes. Capture-only for now
// (create/list/delete/download); restore is a separate later phase.
// Per-workload archives of host-bind data volumes: create/list/delete/
// download, plus restore (overwrites live data + restarts the app).
export interface SnapshotInfo {
id: string;
@@ -629,6 +629,20 @@ export function snapshotDownloadUrl(sid: string): string {
return `/api/snapshots/${sid}/download`;
}
export function restoreSnapshot(
workloadId: string,
sid: string
): Promise<{ status: string; workload_id: string; snapshot_id: string }> {
// X-Confirm-Restore echoes the snapshot id (same CSRF guard as the DB
// restore): the backend rejects any POST whose header doesn't match the
// path param, defeating blind cross-origin POSTs that can't set custom
// headers without a preflight. Sent alongside the bearer JWT.
return request(`/api/workloads/${workloadId}/snapshots/${sid}/restore`, {
method: 'POST',
headers: { 'X-Confirm-Restore': sid }
});
}
// ── Health ──────────────────────────────────────────────────────────
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
@@ -3,8 +3,9 @@
* WorkloadSnapshotsPanel
*
* Per-workload capture of host-bind data volumes (tar.gz). Create / list /
* download / delete. Restore is intentionally NOT here yet — overwriting
* live data needs container quiesce + atomic swap and ships separately.
* download / delete / restore. Restore overwrites the app's live volume data
* and recreates its containers — it quiesces the app, atomically swaps each
* volume dir, then redeploys, and auto-captures a pre-restore snapshot first.
*
* "Snapshotable" coverage is shown up-front (and which volumes are skipped,
* with why) so users are never misled about what is actually captured.
@@ -29,6 +30,8 @@
let error = $state('');
let label = $state('');
let confirmDeleteId = $state<string | null>(null);
let confirmRestoreId = $state<string | null>(null);
let restoringId = $state<string | null>(null);
const canSnapshot = $derived((snapshotable?.volumes.length ?? 0) > 0);
@@ -81,6 +84,20 @@
}
}
async function doRestore(id: string): Promise<void> {
confirmRestoreId = null;
restoringId = id;
try {
await api.restoreSnapshot(workloadId, id);
toasts.success($t('apps.detail.snapshots.restored'));
await load();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.restoreFailed'));
} finally {
restoringId = null;
}
}
async function download(snap: api.SnapshotInfo): Promise<void> {
try {
const token = getAuthToken();
@@ -194,12 +211,26 @@
<td>{volCount(snap.manifest)}</td>
<td class="mono-time">{formatBytes(snap.size_bytes)}</td>
<td class="snap-actions">
<button class="forge-btn-ghost" onclick={() => download(snap)}>
<button
class="forge-btn-ghost"
onclick={() => (confirmRestoreId = snap.id)}
disabled={restoringId !== null}
>
{restoringId === snap.id
? $t('apps.detail.snapshots.restoring')
: $t('apps.detail.snapshots.restore')}
</button>
<button
class="forge-btn-ghost"
onclick={() => download(snap)}
disabled={restoringId !== null}
>
{$t('apps.detail.snapshots.download')}
</button>
<button
class="forge-btn-ghost danger"
onclick={() => (confirmDeleteId = snap.id)}
disabled={restoringId !== null}
aria-label={$t('apps.detail.snapshots.delete')}
>
<IconTrash size={13} />
@@ -225,6 +256,18 @@
/>
{/if}
{#if confirmRestoreId}
<ConfirmDialog
open={true}
title={$t('apps.detail.snapshots.confirmRestoreTitle')}
message={$t('apps.detail.snapshots.confirmRestoreMessage')}
confirmLabel={$t('apps.detail.snapshots.restore')}
confirmVariant="danger"
onconfirm={() => confirmRestoreId && doRestore(confirmRestoreId)}
oncancel={() => (confirmRestoreId = null)}
/>
{/if}
<style>
.snap-panel {
margin-top: 1rem;
+7 -1
View File
@@ -1706,7 +1706,13 @@
"downloadFailed": "Failed to download snapshot",
"deleteFailed": "Failed to delete snapshot",
"confirmDeleteTitle": "Delete snapshot?",
"confirmDeleteMessage": "This permanently deletes the snapshot archive. This cannot be undone."
"confirmDeleteMessage": "This permanently deletes the snapshot archive. This cannot be undone.",
"restore": "Restore",
"restoring": "Restoring…",
"restored": "Snapshot restored and the app redeployed.",
"restoreFailed": "Failed to restore snapshot",
"confirmRestoreTitle": "Restore this snapshot?",
"confirmRestoreMessage": "This OVERWRITES the app's live volume data with this snapshot and restarts the app. A pre-restore snapshot of the current data is captured automatically first, so you can roll back. Continue?"
},
"deployHistory": {
"title": "Deploy history",
+7 -1
View File
@@ -1706,7 +1706,13 @@
"downloadFailed": "Не удалось скачать снимок",
"deleteFailed": "Не удалось удалить снимок",
"confirmDeleteTitle": "Удалить снимок?",
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить."
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить.",
"restore": "Восстановить",
"restoring": "Восстановление…",
"restored": "Снимок восстановлен, приложение переразвёрнуто.",
"restoreFailed": "Не удалось восстановить снимок",
"confirmRestoreTitle": "Восстановить этот снимок?",
"confirmRestoreMessage": "Это ПЕРЕЗАПИШЕТ текущие данные томов приложения этим снимком и перезапустит приложение. Сначала автоматически создаётся снимок текущих данных, чтобы можно было откатиться. Продолжить?"
},
"deployHistory": {
"title": "История деплоев",