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
@@ -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;