remove backup journal, stop-recovery, and verify modal (deleted upstream)
This commit is contained in:
4 files changed
-327
No files matched your search
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure core of the backup operation journal — NO database import, so it is unit
|
|
||||||
* testable under bun test (journal.ts pulls better-sqlite3 transitively and
|
|
||||||
* cannot be). All persistence is behind the injected `JournalStore` interface;
|
|
||||||
* journal.ts supplies the settings-backed implementation.
|
|
||||||
*
|
|
||||||
* The property this core encodes and the tests pin: record/clear/list operate on
|
|
||||||
* PER-KEY rows (one settings row per operation id), so two concurrent records
|
|
||||||
* never lost-update each other the way a single shared JSON array would.
|
|
||||||
*/
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
|
|
||||||
/** One durable row per key. `backup:op:<id>`. */
|
|
||||||
export const OP_KEY_PREFIX = 'backup:op:';
|
|
||||||
|
|
||||||
/** The kinds of in-flight operation the journal records. */
|
|
||||||
export type OpKind = 'swap' | 'stop';
|
|
||||||
|
|
||||||
export interface OperationRecord {
|
|
||||||
id: string;
|
|
||||||
kind: OpKind;
|
|
||||||
instanceId: string;
|
|
||||||
envId?: number | null;
|
|
||||||
startedAt: string;
|
|
||||||
// kind: 'stop'
|
|
||||||
stopType?: 'stack' | 'container';
|
|
||||||
targetName?: string;
|
|
||||||
containerId?: string;
|
|
||||||
configId?: number;
|
|
||||||
/** For a 'stop' of type 'container': the ids/names that were running and got
|
|
||||||
* stopped, so a fresh process can start exactly those back up. */
|
|
||||||
containers?: Array<{ id: string; name: string }>;
|
|
||||||
// kind: 'swap'
|
|
||||||
snapshotId?: string;
|
|
||||||
includes?: string[];
|
|
||||||
volumeBinds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal per-key settings store the core depends on (injected). */
|
|
||||||
export interface JournalStore {
|
|
||||||
get(key: string): Promise<any>;
|
|
||||||
set(key: string, value: any): Promise<void>;
|
|
||||||
delete(key: string): Promise<void>;
|
|
||||||
listByPrefix(prefix: string): Promise<Array<{ key: string; value: any }>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rowKey(id: string): string {
|
|
||||||
return OP_KEY_PREFIX + id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recordOperationIn(
|
|
||||||
store: JournalStore,
|
|
||||||
rec: Omit<OperationRecord, 'id'> & { id?: string }
|
|
||||||
): Promise<string> {
|
|
||||||
const id = rec.id ?? randomUUID();
|
|
||||||
const full: OperationRecord = { ...rec, id };
|
|
||||||
try {
|
|
||||||
// Single-key upsert. No read-modify-write of a shared row → no lost update.
|
|
||||||
await store.set(rowKey(id), full);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[Backup] Could not persist ${rec.kind} operation ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearOperationIn(store: JournalStore, id: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await store.delete(rowKey(id));
|
|
||||||
} catch { /* best-effort */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOperationsIn(
|
|
||||||
store: JournalStore,
|
|
||||||
kind?: OpKind,
|
|
||||||
instanceId?: string
|
|
||||||
): Promise<OperationRecord[]> {
|
|
||||||
let rows: Array<{ key: string; value: any }>;
|
|
||||||
try {
|
|
||||||
rows = await store.listByPrefix(OP_KEY_PREFIX);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const out: OperationRecord[] = [];
|
|
||||||
for (const r of rows) {
|
|
||||||
const rec = r.value as OperationRecord;
|
|
||||||
if (!rec || typeof rec !== 'object' || !rec.kind) continue;
|
|
||||||
if (kind && rec.kind !== kind) continue;
|
|
||||||
if (instanceId && rec.instanceId !== instanceId) continue;
|
|
||||||
out.push(rec);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Durable operation journal for the backup/restore domain — settings-backed
|
|
||||||
* adapter over the pure core in journal-core.ts.
|
|
||||||
*
|
|
||||||
* Each in-flight operation gets its OWN settings row keyed `backup:op:<id>`:
|
|
||||||
* record = single-key upsert; clear = single-key delete; list = prefix scan. Two
|
|
||||||
* distinct operation ids write distinct rows and so can never lost-update each
|
|
||||||
* other — the failure mode a single shared JSON array mutated by a lock-free
|
|
||||||
* read-filter-write suffers under concurrency. The row carries the owning
|
|
||||||
* instanceId so a list can be scoped to this installation. The record shape lives
|
|
||||||
* in journal-core.ts (no db import) so it is unit-testable.
|
|
||||||
*
|
|
||||||
* This module is a LEAF: it depends only on the settings store, so it never
|
|
||||||
* imports the pipelines that use it (no cycle).
|
|
||||||
*/
|
|
||||||
import { getSetting, setSetting, deleteSetting, listSettingsByPrefix } from '../db';
|
|
||||||
import {
|
|
||||||
recordOperationIn, clearOperationIn, listOperationsIn,
|
|
||||||
type JournalStore, type OperationRecord, type OpKind,
|
|
||||||
} from './journal-core';
|
|
||||||
|
|
||||||
export type { OperationRecord, OpKind } from './journal-core';
|
|
||||||
|
|
||||||
/** The production store: dockhand's settings table. */
|
|
||||||
const settingsStore: JournalStore = {
|
|
||||||
get: (key) => getSetting(key),
|
|
||||||
set: (key, value) => setSetting(key, value),
|
|
||||||
delete: (key) => deleteSetting(key),
|
|
||||||
listByPrefix: (prefix) => listSettingsByPrefix(prefix),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function recordOperation(rec: Omit<OperationRecord, 'id'> & { id?: string }): Promise<string> {
|
|
||||||
return recordOperationIn(settingsStore, rec);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearOperation(id: string): Promise<void> {
|
|
||||||
return clearOperationIn(settingsStore, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listOperations(kind?: OpKind, instanceId?: string): Promise<OperationRecord[]> {
|
|
||||||
return listOperationsIn(settingsStore, kind, instanceId);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure decision core for recovering containers/stacks that were stopped for a
|
|
||||||
* consistent backup (or an in-place restore) but never restarted — because the
|
|
||||||
* process died between the stop and the in-process restart. NO database or
|
|
||||||
* docker import, so it is unit-testable under bun test.
|
|
||||||
*
|
|
||||||
* `reconcileOnStartup` records a durable 'stop' intent BEFORE it stops a target
|
|
||||||
* and clears it when the in-process restart succeeds. Anything left in the
|
|
||||||
* journal on the next startup is a target that is still down; this core turns
|
|
||||||
* each such intent into the concrete restart action the composition layer must
|
|
||||||
* run. It carries NO effects — it only decides — so the ordering and mapping can
|
|
||||||
* be proven without a live docker daemon.
|
|
||||||
*/
|
|
||||||
import type { OperationRecord } from './journal-core';
|
|
||||||
|
|
||||||
/** A stable per-target key for the durable 'stop' intent, so re-recording the
|
|
||||||
* same target updates in place rather than piling up rows. Mirrors how the
|
|
||||||
* 'swap' intent is keyed. */
|
|
||||||
export function stopIntentKey(envId: number | null | undefined, type: 'container' | 'stack', targetName: string): string {
|
|
||||||
return `stop:${envId ?? 'local'}:${type}:${targetName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One concrete restart to perform for an interrupted 'stop' intent. */
|
|
||||||
export type RestartAction =
|
|
||||||
| { intentId: string; type: 'stack'; targetName: string; envId: number | null | undefined }
|
|
||||||
| { intentId: string; type: 'container'; envId: number | null | undefined; containers: Array<{ id: string; name: string }> };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether an error from inspecting a stop-intent's target means the target is
|
|
||||||
* GONE (deleted) rather than temporarily unreachable. Only a Docker 404 is
|
|
||||||
* "gone" — the container/stack no longer exists, so a restart can never succeed
|
|
||||||
* and the intent must be dropped. Any other failure (env offline, daemon down,
|
|
||||||
* 5xx) is transient: the target may still exist, so the intent is kept for a
|
|
||||||
* later startup to retry. Pure so the classification is unit-testable.
|
|
||||||
*/
|
|
||||||
export function isTargetGoneError(err: unknown): boolean {
|
|
||||||
return (err as { statusCode?: number } | null)?.statusCode === 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map recorded 'stop' intents to restart actions, skipping any malformed row.
|
|
||||||
* A stack intent restarts the stack by name; a container intent restarts each
|
|
||||||
* recorded container id. A container intent with no recorded containers is
|
|
||||||
* skipped (nothing to restart); the caller drops such rows via the
|
|
||||||
* target-existence check in reconcileOnStartup.
|
|
||||||
*/
|
|
||||||
export function planStopRecovery(intents: OperationRecord[]): RestartAction[] {
|
|
||||||
const actions: RestartAction[] = [];
|
|
||||||
for (const rec of intents) {
|
|
||||||
if (rec.kind !== 'stop') continue;
|
|
||||||
if (rec.stopType === 'stack') {
|
|
||||||
if (!rec.targetName) continue;
|
|
||||||
actions.push({ intentId: rec.id, type: 'stack', targetName: rec.targetName, envId: rec.envId });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// container (default)
|
|
||||||
const containers = (rec.containers ?? []).filter((c) => c && c.id);
|
|
||||||
if (containers.length === 0) continue;
|
|
||||||
actions.push({ intentId: rec.id, type: 'container', envId: rec.envId, containers });
|
|
||||||
}
|
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { ShieldCheck, Loader2, CheckCircle, XCircle } from 'lucide-svelte';
|
|
||||||
import { watchJob } from '$lib/utils/sse-fetch';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
destinationId: number;
|
|
||||||
destinationName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { open = $bindable(), destinationId, destinationName }: Props = $props();
|
|
||||||
|
|
||||||
let dataSubset = $state('5%');
|
|
||||||
let running = $state(false);
|
|
||||||
let status = $state<'idle' | 'running' | 'success' | 'error'>('idle');
|
|
||||||
let logs = $state<string[]>([]);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (open) {
|
|
||||||
status = 'idle';
|
|
||||||
running = false;
|
|
||||||
logs = [];
|
|
||||||
error = '';
|
|
||||||
dataSubset = '5%';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function runVerify() {
|
|
||||||
running = true;
|
|
||||||
status = 'running';
|
|
||||||
logs = [`Starting verification (reading ${dataSubset} of data)...`];
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/backup/destinations/${destinationId}/verify`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ dataSubset })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.jobId) {
|
|
||||||
await watchJob(data.jobId, (line) => {
|
|
||||||
const msg = (line.data as { message?: string } | null)?.message;
|
|
||||||
if (line.event === 'progress' && msg) {
|
|
||||||
logs = [...logs, msg];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
status = 'success';
|
|
||||||
logs = [...logs, 'Verification passed'];
|
|
||||||
} else if (data.error) {
|
|
||||||
status = 'error';
|
|
||||||
error = data.error;
|
|
||||||
} else {
|
|
||||||
status = data.success ? 'success' : 'error';
|
|
||||||
if (!data.success) error = data.error || 'Unknown error';
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
status = 'error';
|
|
||||||
error = err.message || 'Verification failed';
|
|
||||||
} finally {
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
|
||||||
<Dialog.Content class="max-w-lg max-h-[70vh] flex flex-col">
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
|
||||||
<ShieldCheck class="w-4 h-4" />Verify backup integrity
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Description>{destinationName}</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
{#if status === 'idle'}
|
|
||||||
<div class="space-y-3 py-2">
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Reads a random subset of data packs from the repository and verifies they are intact and readable. Larger subsets take longer but provide more thorough verification.
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-xs text-muted-foreground shrink-0">Data to verify:</span>
|
|
||||||
<Select.Root type="single" value={dataSubset} onValueChange={(v) => dataSubset = v}>
|
|
||||||
<Select.Trigger class="h-8 w-28 text-xs">{dataSubset}</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item value="5%">5% (quick)</Select.Item>
|
|
||||||
<Select.Item value="10%">10%</Select.Item>
|
|
||||||
<Select.Item value="25%">25%</Select.Item>
|
|
||||||
<Select.Item value="50%">50%</Select.Item>
|
|
||||||
<Select.Item value="100%">100% (full)</Select.Item>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="outline" size="sm" onclick={() => open = false}>Cancel</Button>
|
|
||||||
<Button size="sm" onclick={runVerify}>
|
|
||||||
<ShieldCheck class="w-3.5 h-3.5 mr-1.5" />Verify
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex-1 overflow-y-auto border rounded bg-muted/30 p-2 font-mono text-[11px] space-y-0.5 min-h-[200px]">
|
|
||||||
{#each logs as line}
|
|
||||||
<div class="text-muted-foreground">{line}</div>
|
|
||||||
{/each}
|
|
||||||
{#if status === 'running'}
|
|
||||||
<div class="flex items-center gap-1.5 text-primary">
|
|
||||||
<Loader2 class="w-3 h-3 animate-spin" />Running...
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if status === 'success'}
|
|
||||||
<div class="flex items-center gap-1.5 text-green-500 font-medium mt-2">
|
|
||||||
<CheckCircle class="w-3.5 h-3.5" />Verification passed
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if status === 'error'}
|
|
||||||
<div class="flex items-center gap-1.5 text-destructive font-medium mt-2">
|
|
||||||
<XCircle class="w-3.5 h-3.5" />{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end pt-2">
|
|
||||||
<Button variant="outline" size="sm" onclick={() => open = false}>Close</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
Reference in new issue
Block a user