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:
+16
-2
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1706,7 +1706,13 @@
|
||||
"downloadFailed": "Не удалось скачать снимок",
|
||||
"deleteFailed": "Не удалось удалить снимок",
|
||||
"confirmDeleteTitle": "Удалить снимок?",
|
||||
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить."
|
||||
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить.",
|
||||
"restore": "Восстановить",
|
||||
"restoring": "Восстановление…",
|
||||
"restored": "Снимок восстановлен, приложение переразвёрнуто.",
|
||||
"restoreFailed": "Не удалось восстановить снимок",
|
||||
"confirmRestoreTitle": "Восстановить этот снимок?",
|
||||
"confirmRestoreMessage": "Это ПЕРЕЗАПИШЕТ текущие данные томов приложения этим снимком и перезапустит приложение. Сначала автоматически создаётся снимок текущих данных, чтобы можно было откатиться. Продолжить?"
|
||||
},
|
||||
"deployHistory": {
|
||||
"title": "История деплоев",
|
||||
|
||||
Reference in New Issue
Block a user