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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user