read-side backup snapshot endpoints use job polling
This commit is contained in:
15 files changed
+224
-165
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { getInstanceId } from '$lib/server/backups/identity';
|
||||
|
||||
// This install's stable instance id. The snapshots list used to ride it on an
|
||||
// X-Dockhand-Instance response header, but that list is now job-polled (a proxy would
|
||||
// abort the sync `restic snapshots` at ~15s) and headers don't survive polling. The
|
||||
// client fetches the id here once and matches it against each snapshot's
|
||||
// dockhand:instance= tag to tell own snapshots from foreign orphans (#1351).
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
return json({ instanceId: await getInstanceId() });
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { previewSnapshot, previewRestoreTargets } from '$lib/server/backups';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -26,7 +27,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId);
|
||||
if (denied) return denied;
|
||||
|
||||
try {
|
||||
// Job-polling: the preview runs restic (verify-ownership + ls/dump) and, with a mode,
|
||||
// per-volume probe helpers - all slow enough that a reverse proxy would abort the sync
|
||||
// request at ~15s and SIGTERM the restic mid-op (the "wrong password / signal terminated"
|
||||
// red herrings). Returning {jobId} immediately keeps the connection alive; the client polls.
|
||||
return jobResult(request, async () => {
|
||||
const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) };
|
||||
const preview = await previewSnapshot(body.destinationId, body.snapshotId, access);
|
||||
// When the caller supplies a restore mode, ALSO resolve the exact on-disk targets and probe
|
||||
@@ -44,11 +49,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
mergeStackFiles: body.mergeStackFiles,
|
||||
volumes: body.volumes,
|
||||
}, access);
|
||||
return json({ ...preview, targets });
|
||||
return { ...preview, targets };
|
||||
}
|
||||
return json(preview);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
return preview;
|
||||
});
|
||||
};
|
||||
@@ -5,8 +5,9 @@ import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { getBackupConfig, getBackupDestinations } from '$lib/server/db';
|
||||
import { listSnapshots } from '$lib/server/backups';
|
||||
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
@@ -30,13 +31,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
// Narrow to THIS config's identity by its stable config-id tag so a sibling
|
||||
// config sharing the same destination + targetName isn't mixed in.
|
||||
if (allDests) {
|
||||
// Search ALL destinations for this config's snapshots
|
||||
try {
|
||||
// Search ALL destinations for this config's snapshots.
|
||||
// Job-polling: `restic snapshots` across every destination is the slowest read here
|
||||
// and a reverse proxy would abort the sync request at ~15s. The result stays a bare
|
||||
// snapshot array (unchanged contract - many callers/tests treat it as an array); the
|
||||
// old X-Incomplete-Destinations header is now log-only.
|
||||
return jobResult(request, async () => {
|
||||
const destinations = await getBackupDestinations();
|
||||
// Bound each destination's listing so one slow or unreachable repo
|
||||
// can't stall the whole response for minutes (restic's own timeout is
|
||||
// several minutes). A timed-out destination is treated as a failed
|
||||
// one — surfaced as a partial result via X-Incomplete-Destinations.
|
||||
// several minutes). A timed-out destination is treated as a failed one.
|
||||
const PER_DEST_MS = Number(process.env.SNAPSHOT_LIST_TIMEOUT_MS ?? 30_000);
|
||||
const withTimeout = <T>(p: Promise<T>, name: string): Promise<T> =>
|
||||
Promise.race([
|
||||
@@ -50,10 +54,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
);
|
||||
const allSnapshots: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
// (audit #29) Don't silently drop destinations that errored. Collect
|
||||
// the failures and surface them via a response header (keeping the body
|
||||
// a plain array for compatibility), plus a server log — so a partial
|
||||
// result doesn't look complete.
|
||||
// (audit #29) Don't silently drop destinations that errored. Collect the
|
||||
// failures and surface a count so a partial result doesn't look complete.
|
||||
const failed: string[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
@@ -72,48 +74,33 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
allSnapshots.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||
if (failed.length > 0) {
|
||||
console.warn(`[Backup] Snapshot search incomplete — ${failed.length} destination(s) failed: ${failed.join('; ')}`);
|
||||
return json(allSnapshots, { headers: { 'X-Incomplete-Destinations': String(failed.length) } });
|
||||
}
|
||||
return json(allSnapshots);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
return allSnapshots;
|
||||
});
|
||||
}
|
||||
|
||||
// Default: only search the config's current destination
|
||||
try {
|
||||
const snapshots = await listSnapshots(config.destinationId, config.id);
|
||||
return json(snapshots);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
// Default: only search the config's current destination.
|
||||
return jobResult(request, () => listSnapshots(config.destinationId, config.id));
|
||||
} else if (destIdParam) {
|
||||
const destinationId = parseInt(destIdParam);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
|
||||
|
||||
try {
|
||||
// Job-polling: `restic snapshots` would otherwise be aborted at the proxy's ~15s cap.
|
||||
// Returns a bare snapshot array (unchanged contract). The per-snapshot dockhand:instance=
|
||||
// tag identifies the writing install; the client learns its OWN id from
|
||||
// GET /api/backup/instance (the old X-Dockhand-Instance header can't ride job polling).
|
||||
return jobResult(request, async () => {
|
||||
// No target filter — return all snapshots in this destination (incl. snapshots
|
||||
// from OTHER Dockhand instances sharing/copied into the repo). The response carries
|
||||
// this install's instance id so the client can tell own snapshots from foreign
|
||||
// orphans without relying on configid (which collides across instances - #1351).
|
||||
const { getInstanceId } = await import('$lib/server/backups/identity');
|
||||
const thisInstanceId = await getInstanceId();
|
||||
// from OTHER Dockhand instances sharing/copied into the repo).
|
||||
const snapshots = await listSnapshots(destinationId);
|
||||
// (HIGH #8) On enterprise, a non-admin must not see snapshots belonging to
|
||||
// environments they can't access — the ids/paths alone are disclosure. Drop
|
||||
// any snapshot whose owning env (from its dockhand:env tag) isn't accessible.
|
||||
const headers = { 'X-Dockhand-Instance': thisInstanceId };
|
||||
if (auth.isEnterprise) {
|
||||
const filtered = await filterSnapshotsByEnvAccess(auth, snapshots);
|
||||
return json(filtered, { headers });
|
||||
return await filterSnapshotsByEnvAccess(auth, snapshots);
|
||||
}
|
||||
return json(snapshots, { headers });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
return snapshots;
|
||||
});
|
||||
} else {
|
||||
return json({ error: 'configId or destinationId parameter is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { browseSnapshot } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
@@ -38,11 +39,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
return json({ error: 'Environment access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Job-polling: `restic ls` behind a reverse proxy would abort at ~15s and SIGTERM restic
|
||||
// (surfacing a misleading "wrong password / signal terminated"). Return {jobId} at once.
|
||||
return jobResult(request, async () => {
|
||||
const entries = await browseSnapshot(destinationId, snapshotId, path);
|
||||
return json({ entries, path });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
return { entries, path };
|
||||
});
|
||||
};
|
||||
@@ -6,8 +6,9 @@ import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
@@ -58,35 +59,35 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
if (download) {
|
||||
return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
// Job-polling: `restic dump` behind a reverse proxy would abort at ~15s.
|
||||
return jobResult(request, async () => {
|
||||
const raw = await dumpSnapshotFile(destinationId, snapshotId, '/metadata/metadata.json');
|
||||
const layout = parseSnapshotLayout(raw);
|
||||
if (!layout) return json({ error: 'metadata unreadable' }, { status: 404 });
|
||||
return json({ content: JSON.stringify(redactSnapshotLayout(layout), null, 2) });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
if (!layout) return { error: 'metadata unreadable' };
|
||||
return { content: JSON.stringify(redactSnapshotLayout(layout), null, 2) };
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
|
||||
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
|
||||
|
||||
try {
|
||||
if (download && isDir) {
|
||||
// Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip
|
||||
// would corrupt any non-ASCII byte in the archive).
|
||||
const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path);
|
||||
const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar');
|
||||
return new Response(new Uint8Array(tarData), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-tar',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (download) {
|
||||
// Binary downloads are window.open navigations, not fetch()es — they can't poll a job,
|
||||
// so they stay synchronous. (A raw byte/tar stream also can't be JSON-wrapped.) These
|
||||
// stream restic's output, so the proxy sees bytes flowing and won't idle-abort them.
|
||||
if (download) {
|
||||
try {
|
||||
if (isDir) {
|
||||
// Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip
|
||||
// would corrupt any non-ASCII byte in the archive).
|
||||
const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path);
|
||||
const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar');
|
||||
return new Response(new Uint8Array(tarData), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-tar',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
// A file download may be binary — serve raw bytes, not a decoded string.
|
||||
const bytes = await dumpSnapshotFileBytes(destinationId, snapshotId, path);
|
||||
const filename = sanitizeFilename(path.split('/').pop() || 'file');
|
||||
@@ -96,13 +97,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
// Inline preview — text only.
|
||||
const content = await dumpSnapshotFile(destinationId, snapshotId, path);
|
||||
return json({ content });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
// Inline preview (text only) — job-polling: `restic dump` would otherwise be aborted
|
||||
// at the reverse-proxy's ~15s cap.
|
||||
return jobResult(request, async () => {
|
||||
const content = await dumpSnapshotFile(destinationId, snapshotId, path);
|
||||
return { content };
|
||||
});
|
||||
};
|
||||
@@ -6,8 +6,9 @@ import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { getSnapshotMetadata } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
@@ -26,14 +27,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
try {
|
||||
// Job-polling: reading metadata runs `restic dump` which a proxy would abort at ~15s.
|
||||
return jobResult(request, async () => {
|
||||
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
||||
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
||||
if (!metadata) return { error: 'No metadata available' };
|
||||
// Single redaction point: strips stack.secrets values AND container inspect
|
||||
// Config.Env/Labels (plaintext secrets) before anything reaches the client.
|
||||
return json(redactSnapshotLayout(metadata));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
return redactSnapshotLayout(metadata);
|
||||
});
|
||||
};
|
||||
@@ -5,8 +5,9 @@ import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { diffSnapshots } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
@@ -31,11 +32,6 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const deniedB = await guardSnapshotEnvAccess(auth, destinationId, snapB);
|
||||
if (deniedB) return deniedB;
|
||||
|
||||
try {
|
||||
const result = await diffSnapshots(destinationId, snapA, snapB);
|
||||
return json(result);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
// Job-polling: `restic diff` runs two restic reads a proxy would abort at ~15s.
|
||||
return jobResult(request, () => diffSnapshots(destinationId, snapA, snapB));
|
||||
};
|
||||
Reference in new issue
Block a user