1c47030854
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/
319 lines
8.9 KiB
Svelte
319 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* WorkloadSnapshotsPanel
|
|
*
|
|
* Per-workload capture of host-bind data volumes (tar.gz). Create / list /
|
|
* 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.
|
|
*/
|
|
import * as api from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { toasts } from '$lib/stores/toast';
|
|
import { getAuthToken } from '$lib/auth';
|
|
import { formatBytes } from '$lib/format/bytes';
|
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
import { IconPlus, IconTrash } from './icons';
|
|
|
|
interface Props {
|
|
workloadId: string;
|
|
}
|
|
let { workloadId }: Props = $props();
|
|
|
|
let snapshots = $state<api.SnapshotInfo[]>([]);
|
|
let snapshotable = $state<api.SnapshotableInfo | null>(null);
|
|
let loading = $state(true);
|
|
let creating = $state(false);
|
|
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);
|
|
|
|
async function load(): Promise<void> {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
[snapshots, snapshotable] = await Promise.all([
|
|
api.listWorkloadSnapshots(workloadId),
|
|
api.getWorkloadSnapshotable(workloadId)
|
|
]);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Reload whenever workloadId changes — the parent reuses this instance
|
|
// across /apps/A → /apps/B navigation.
|
|
$effect(() => {
|
|
const _ = workloadId;
|
|
load();
|
|
});
|
|
|
|
async function create(): Promise<void> {
|
|
if (creating || !canSnapshot) return;
|
|
creating = true;
|
|
try {
|
|
await api.createWorkloadSnapshot(workloadId, label.trim() || undefined);
|
|
label = '';
|
|
toasts.success($t('apps.detail.snapshots.created'));
|
|
await load();
|
|
} catch (e) {
|
|
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.createFailed'));
|
|
} finally {
|
|
creating = false;
|
|
}
|
|
}
|
|
|
|
async function doDelete(id: string): Promise<void> {
|
|
try {
|
|
await api.deleteSnapshot(id);
|
|
snapshots = snapshots.filter((s) => s.id !== id);
|
|
toasts.success($t('apps.detail.snapshots.deleted'));
|
|
} catch (e) {
|
|
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.deleteFailed'));
|
|
} finally {
|
|
confirmDeleteId = null;
|
|
}
|
|
}
|
|
|
|
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();
|
|
const res = await fetch(api.snapshotDownloadUrl(snap.id), {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = snap.filename;
|
|
a.click();
|
|
// Defer revocation: a.click() starts the download asynchronously, so
|
|
// revoking synchronously can race the navigation in some engines.
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (e) {
|
|
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.downloadFailed'));
|
|
}
|
|
}
|
|
|
|
function volCount(manifest: string): number {
|
|
try {
|
|
return (JSON.parse(manifest) as unknown[]).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="panel snap-panel" aria-labelledby="snap-heading">
|
|
<span class="reg reg-tl" aria-hidden="true"></span>
|
|
<span class="reg reg-tr" aria-hidden="true"></span>
|
|
<span class="reg reg-bl" aria-hidden="true"></span>
|
|
<span class="reg reg-br" aria-hidden="true"></span>
|
|
|
|
<header class="panel-head">
|
|
<h2 class="panel-title" id="snap-heading">
|
|
{$t('apps.detail.snapshots.title')}<span class="title-accent">.</span>
|
|
</h2>
|
|
<span class="panel-sub">{$t('apps.detail.snapshots.sub')}</span>
|
|
</header>
|
|
|
|
{#if error}
|
|
<div class="alert inline-alert" role="alert">
|
|
<span class="alert-tag">ERR</span><span>{error}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if loading}
|
|
<p class="hint">{$t('apps.detail.snapshots.loading')}</p>
|
|
{:else}
|
|
<!-- Coverage summary -->
|
|
{#if canSnapshot}
|
|
<p class="hint">
|
|
{$t('apps.detail.snapshots.coverage', { count: String(snapshotable?.volumes.length ?? 0) })}
|
|
</p>
|
|
<p class="hint warn">{$t('apps.detail.snapshots.liveWarning')}</p>
|
|
{:else}
|
|
<p class="hint">{$t('apps.detail.snapshots.noCoverage')}</p>
|
|
{/if}
|
|
|
|
{#if snapshotable && snapshotable.skipped.length > 0}
|
|
<div class="skipped">
|
|
<span class="skipped-title"
|
|
>{$t('apps.detail.snapshots.skippedTitle', { count: String(snapshotable.skipped.length) })}</span
|
|
>
|
|
<ul>
|
|
{#each snapshotable.skipped as sk (sk.target)}
|
|
<li><span class="mono">{sk.target}</span> — {sk.reason}</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Create -->
|
|
<div class="snap-create">
|
|
<input
|
|
type="text"
|
|
class="input"
|
|
bind:value={label}
|
|
placeholder={$t('apps.detail.snapshots.labelPlaceholder')}
|
|
autocomplete="off"
|
|
disabled={!canSnapshot || creating}
|
|
/>
|
|
<button class="forge-btn" onclick={create} disabled={!canSnapshot || creating}>
|
|
<IconPlus size={13} />
|
|
<span>{creating ? $t('apps.detail.snapshots.creating') : $t('apps.detail.snapshots.create')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- List -->
|
|
{#if snapshots.length === 0}
|
|
<p class="hint">{$t('apps.detail.snapshots.empty')}</p>
|
|
{:else}
|
|
<table class="forge-table snap-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{$t('apps.detail.snapshots.colLabel')}</th>
|
|
<th>{$t('apps.detail.snapshots.colCreated')}</th>
|
|
<th>{$t('apps.detail.snapshots.colVolumes')}</th>
|
|
<th>{$t('apps.detail.snapshots.colSize')}</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each snapshots as snap (snap.id)}
|
|
<tr>
|
|
<td>{snap.label || $t('apps.detail.snapshots.unlabeled')}</td>
|
|
<td class="mono-time">{snap.created_at}</td>
|
|
<td>{volCount(snap.manifest)}</td>
|
|
<td class="mono-time">{formatBytes(snap.size_bytes)}</td>
|
|
<td class="snap-actions">
|
|
<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} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
{#if confirmDeleteId}
|
|
<ConfirmDialog
|
|
open={true}
|
|
title={$t('apps.detail.snapshots.confirmDeleteTitle')}
|
|
message={$t('apps.detail.snapshots.confirmDeleteMessage')}
|
|
confirmLabel={$t('apps.detail.snapshots.delete')}
|
|
confirmVariant="danger"
|
|
onconfirm={() => confirmDeleteId && doDelete(confirmDeleteId)}
|
|
oncancel={() => (confirmDeleteId = null)}
|
|
/>
|
|
{/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;
|
|
}
|
|
.hint {
|
|
font-size: 0.72rem;
|
|
color: var(--text-tertiary);
|
|
margin: 0.3rem 0;
|
|
}
|
|
.hint.warn {
|
|
color: var(--accent-warm, #c08458);
|
|
}
|
|
.skipped {
|
|
margin: 0.5rem 0;
|
|
padding: 0.5rem 0.7rem;
|
|
border: 1px dashed var(--border-primary);
|
|
border-radius: 4px;
|
|
}
|
|
.skipped-title {
|
|
font-size: 0.7rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
}
|
|
.skipped ul {
|
|
margin: 0.35rem 0 0;
|
|
padding-left: 1.1rem;
|
|
font-size: 0.74rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
.snap-create {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin: 0.85rem 0;
|
|
}
|
|
.snap-create .input {
|
|
flex: 1;
|
|
}
|
|
.snap-table {
|
|
width: 100%;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.snap-actions {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
justify-content: flex-end;
|
|
}
|
|
</style>
|