backup engine fixes: shared-repo unlock, non-root uid reads, lock release, local-path repo, backblaze key id
This commit is contained in:
15 files changed
+375
-313
No files matched your search
@@ -11,20 +11,22 @@
|
||||
* local-on-remote disabling.
|
||||
*/
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { getRepoTypeIcon, isLocalRepo } from '$lib/utils/backup';
|
||||
import { getRepoTypeIcon, localRepoNeedsSameHost } from '$lib/utils/backup';
|
||||
|
||||
interface Destination {
|
||||
id: number;
|
||||
name: string;
|
||||
repository: string;
|
||||
hostPath?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
/** Bound to the selected destination id. 0 = nothing selected. */
|
||||
value: number;
|
||||
/** Set to true on a remote environment to disable local-path destinations. */
|
||||
disableLocalForRemoteEnv?: boolean;
|
||||
/** The target environment. A local-path destination is disabled for a
|
||||
* remote env unless it declares a hostPath. Omit for a local/socket env. */
|
||||
env?: { connectionType?: string | null; host?: string | null } | null;
|
||||
/** CSS class for the trigger. Defaults to full width. */
|
||||
triggerClass?: string;
|
||||
placeholder?: string;
|
||||
@@ -33,7 +35,7 @@
|
||||
let {
|
||||
destinations,
|
||||
value = $bindable(),
|
||||
disableLocalForRemoteEnv = false,
|
||||
env,
|
||||
triggerClass = 'h-9 w-full',
|
||||
placeholder = 'Select repository...'
|
||||
}: Props = $props();
|
||||
@@ -60,23 +62,16 @@
|
||||
<Select.Content>
|
||||
{#each destinations as dest}
|
||||
{@const DIcon = getRepoTypeIcon(dest.repository)}
|
||||
{@const localOnRemote = disableLocalForRemoteEnv && isLocalRepo(dest.repository)}
|
||||
{#if localOnRemote}
|
||||
<Select.Item value={String(dest.id)} disabled>
|
||||
<span class="flex items-center gap-2 opacity-50">
|
||||
<DIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{dest.name}
|
||||
<span class="text-xs text-muted-foreground">— local</span>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{:else}
|
||||
<Select.Item value={String(dest.id)}>
|
||||
<span class="flex items-center gap-2">
|
||||
<DIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{dest.name}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{@const localOnRemote = localRepoNeedsSameHost(dest, env)}
|
||||
<Select.Item value={String(dest.id)}>
|
||||
<span class="flex items-center gap-2">
|
||||
<DIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{dest.name}
|
||||
{#if localOnRemote}
|
||||
<span class="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400" title="A local-path repo only works if this environment's Docker daemon is on the same host as Dockhand.">needs same host</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
+181
-134
@@ -25,7 +25,7 @@ import { instanceTagFilter, parseSnapshots, retentionTagFilter } from './models'
|
||||
import { buildSwapRecoveryScript } from './swap';
|
||||
import { parseOptionsJson, fireWebhook, parseResticDiff, type SnapshotDiff } from './helpers';
|
||||
import { getHostname } from '../license';
|
||||
import { getBackupConfig, getBackupConfigs, getBackupDestination, updateBackupConfig, updateBackupDestination, decryptBackupDestination, getEnvironment } from '../db';
|
||||
import { getBackupConfig, getBackupConfigs, getBackupDestination, updateBackupConfig, updateBackupDestination, decryptBackupDestination } from '../db';
|
||||
import { getInstanceId } from './identity';
|
||||
import { inspectContainer } from '../docker';
|
||||
import { sendEventNotification } from '../notifications';
|
||||
@@ -175,6 +175,18 @@ async function collectMetadata(
|
||||
console.warn(`[Backup] Stack directory for "${targetName}" exceeded the backup cap (${MAX_FILES} files / ${Math.round(MAX_TOTAL_BYTES / 1024 / 1024)} MB) — only ${count} file(s) / ${bytes} byte(s) captured. The snapshot is INCOMPLETE for redeploy; reduce the stack dir or exclude large sibling files.`);
|
||||
}
|
||||
} catch { /* directory walk best-effort — compose/.env already captured */ }
|
||||
// Carry the stack's secret env vars IN the snapshot so a restore reproduces a
|
||||
// WORKING stack — secrets and all — even after the source stack (and its DB
|
||||
// rows) are gone. Stored as their at-rest ciphertext (enc:v1:...), encrypted
|
||||
// under THIS instance's key: a restore on the same instance decrypts them
|
||||
// transparently; on a fresh instance the operator must carry over the same
|
||||
// encryption key (.encryption_key / ENCRYPTION_KEY) or they stay unreadable.
|
||||
// Values are NEVER stored plaintext (getStackSecretCiphertexts guarantees it).
|
||||
try {
|
||||
const { getStackSecretCiphertexts } = await import('../db');
|
||||
const secrets = await getStackSecretCiphertexts(targetName, envId ?? null);
|
||||
if (secrets.length > 0) metadata.secrets = secrets;
|
||||
} catch { /* best-effort — a lookup failure just omits secrets from the snapshot */ }
|
||||
// Rewrite metadata.json with hasStackFiles set.
|
||||
files[0] = { path: 'metadata.json', contentBase64: Buffer.from(JSON.stringify(metadata, null, 2)).toString('base64') };
|
||||
}
|
||||
@@ -224,6 +236,89 @@ function backupPorts(destination: any, onProgress?: (status: string, message: st
|
||||
// =============================================================================
|
||||
// RestorePorts — the real implementation, bound to one destination + access ctx
|
||||
// =============================================================================
|
||||
/**
|
||||
* Extract a snapshot's captured stack files (/metadata/stacks/<name>/stackfiles/) into
|
||||
* `targetPath` under the stacks root, with a path-traversal guard, a data-loss guard
|
||||
* (an empty extraction never wipes the live dir), and an atomic staging-swap so a
|
||||
* mid-copy failure can't leave the dir half-written. Returns false (no-op) when the
|
||||
* snapshot has no stackfiles. Shared by the writeLocalStackFiles port and the restore
|
||||
* redeploy path (which materialises into the CANONICAL stack dir). `destination` is
|
||||
* already resolved by the caller.
|
||||
*/
|
||||
async function materialiseStackFiles(destination: any, snapId: string, stackName: string, targetPath: string, overwrite: boolean): Promise<boolean> {
|
||||
const { mkdtempSync, mkdirSync, rmSync, existsSync, cpSync, readdirSync, renameSync } = await import('fs');
|
||||
const { tmpdir } = await import('os');
|
||||
const { join, dirname, resolve, sep } = await import('path');
|
||||
const { randomUUID } = await import('crypto');
|
||||
const { getStacksDir } = await import('../stacks');
|
||||
// Use `restic restore --include` (not dump→tar): restore writes a
|
||||
// DETERMINISTIC path under the target — <tmp>/metadata/stacks/<name>/
|
||||
// stackfiles/ — so there's no tar-prefix guessing.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'dh-stackfiles-'));
|
||||
console.log(`[Backup] materialiseStackFiles: "${stackName}" snapshot=${snapId} → extract to ${tmp}, then swap into ${targetPath} (overwrite=${overwrite})`);
|
||||
try {
|
||||
const include = `/metadata/stacks/${stackName}/stackfiles`;
|
||||
const run = await restic.runLocal(destination, ['restore', snapId, '--target', tmp, '--include', include], 'data');
|
||||
if (run.exitCode !== 0) {
|
||||
console.log(`[Backup] materialiseStackFiles: restic restore failed for "${stackName}": ${run.stderr.trim() || `exit ${run.exitCode}`}`);
|
||||
return false;
|
||||
}
|
||||
const extractedDir = join(tmp, 'metadata', 'stacks', stackName, 'stackfiles');
|
||||
// DATA-LOSS guard: only proceed if extraction actually produced files.
|
||||
const extracted = existsSync(extractedDir) ? readdirSync(extractedDir) : [];
|
||||
if (extracted.length === 0) {
|
||||
console.log(`[Backup] materialiseStackFiles: no stackfiles extracted for "${stackName}" (snapshot may predate full-dir capture) — leaving ${targetPath} untouched`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Path-traversal guard: the resolved target MUST stay under the stacks root.
|
||||
const resolvedTarget = resolve(targetPath);
|
||||
const stacksRoot = resolve(getStacksDir());
|
||||
if (resolvedTarget !== stacksRoot && !resolvedTarget.startsWith(stacksRoot + sep)) {
|
||||
console.log(`[Backup] materialiseStackFiles: refusing target "${resolvedTarget}" outside the stacks root "${stacksRoot}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
mkdirSync(resolvedTarget, { recursive: true });
|
||||
|
||||
// Atomic staging-swap: build the desired contents in a sibling staging dir
|
||||
// FIRST, then swap it into place with a same-parent rename. On OVERWRITE the
|
||||
// staging holds ONLY the restored files; on merge it starts as a copy of live.
|
||||
const parent = dirname(resolvedTarget);
|
||||
const staging = mkdtempSync(join(parent, '.dockhand-stack-new-'));
|
||||
const oldAside = join(parent, `.dockhand-stack-old-${randomUUID()}`);
|
||||
try {
|
||||
if (!overwrite) {
|
||||
for (const existing of readdirSync(resolvedTarget)) {
|
||||
cpSync(join(resolvedTarget, existing), join(staging, existing), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const entry of extracted) {
|
||||
cpSync(join(extractedDir, entry), join(staging, entry), { recursive: true, force: true });
|
||||
}
|
||||
renameSync(resolvedTarget, oldAside);
|
||||
try {
|
||||
renameSync(staging, resolvedTarget);
|
||||
} catch (swapErr) {
|
||||
try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
|
||||
throw swapErr;
|
||||
}
|
||||
rmSync(oldAside, { recursive: true, force: true });
|
||||
console.log(`[Backup] materialiseStackFiles: wrote ${extracted.length} entries to ${resolvedTarget} (${overwrite ? 'replaced' : 'merged'})`);
|
||||
return true;
|
||||
} catch (copyErr) {
|
||||
try { rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
if (existsSync(oldAside) && !existsSync(resolvedTarget)) {
|
||||
try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
|
||||
}
|
||||
try { rmSync(oldAside, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
throw copyErr;
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function restorePorts(destination: any, access: { isEnterprise: boolean; canAccessEnvironment: (id: number) => Promise<boolean> }, onProgress?: (status: string, message: string) => void): RestorePorts {
|
||||
const run = boundRunner(destination);
|
||||
return {
|
||||
@@ -313,11 +408,14 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
|
||||
// sibling configs referenced by relative paths resolve, and the file keeps its
|
||||
// real name (e.g. immich.yaml). The backup always captures the full dir plus the
|
||||
// recorded compose filename, so there is no normalized-compose.yaml fallback.
|
||||
redeployStack: async (name, envId, destId, snapId) => {
|
||||
const { redeployStackFromDir } = await import('../stacks');
|
||||
const { mkdtempSync, rmSync, existsSync, readdirSync } = await import('fs');
|
||||
const { tmpdir } = await import('os');
|
||||
redeployStack: async (name, envId, destId, snapId, restoreSecrets) => {
|
||||
const { redeployStackFromDir, getStackDir } = await import('../stacks');
|
||||
const { existsSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
const { upsertStackSource } = await import('../db');
|
||||
|
||||
const log = (msg: string) => console.log(`[Restore:redeploy ${name}] ${msg}`);
|
||||
log(`start: env=${envId ?? 'local'} destination=${destId} snapshot=${snapId}`);
|
||||
|
||||
const meta = await getSnapshotMetadata(destId, snapId);
|
||||
const composeFileName = meta && typeof meta.composeFileName === 'string' ? meta.composeFileName : '';
|
||||
@@ -325,21 +423,57 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
|
||||
throw new Error('snapshot has no recorded compose filename; cannot redeploy this stack');
|
||||
}
|
||||
|
||||
const destination = await loadDest(destId);
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'dh-redeploy-'));
|
||||
try {
|
||||
const include = `/metadata/stacks/${name}/stackfiles`;
|
||||
const run = await restic.runLocal(destination, ['restore', snapId, '--target', tmp, '--include', include], 'data');
|
||||
const extractedDir = join(tmp, 'metadata', 'stacks', name, 'stackfiles');
|
||||
const extractedNames = run.exitCode === 0 && existsSync(extractedDir) ? readdirSync(extractedDir) : [];
|
||||
if (!extractedNames.includes(composeFileName)) {
|
||||
throw new Error(`snapshot stackfiles/ is missing the compose file "${composeFileName}"; cannot redeploy`);
|
||||
// Restore the stack's secrets FROM THE SNAPSHOT (opt-in, default on). They are
|
||||
// stored as at-rest ciphertext encrypted under this instance's key; writing
|
||||
// them into the TARGET env's DB before redeploy lets redeployStackFromDir's
|
||||
// normal secret injection pick them up. setStackEnvVars re-runs encrypt(),
|
||||
// which is a pass-through for an already-encrypted value, so the ciphertext is
|
||||
// stored verbatim (no double-encryption). On a different instance without the
|
||||
// matching key these values won't decrypt — the restore UI warns about that.
|
||||
if (restoreSecrets && Array.isArray(meta?.secrets) && meta.secrets.length > 0) {
|
||||
const { setStackEnvVars } = await import('../db');
|
||||
const secrets = meta.secrets
|
||||
.filter((s: any) => s && typeof s.key === 'string' && typeof s.value === 'string')
|
||||
.map((s: any) => ({ key: s.key, value: s.value, isSecret: true }));
|
||||
if (secrets.length > 0) {
|
||||
await setStackEnvVars(name, envId ?? null, secrets);
|
||||
log(`restored ${secrets.length} secret(s) from snapshot → env ${envId ?? 'local'}`);
|
||||
}
|
||||
const r = await redeployStackFromDir(name, extractedDir, composeFileName, envId ?? undefined);
|
||||
if (!r.success) throw new Error(r.error || 'docker compose up failed');
|
||||
} finally {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Materialise the snapshot's stack files into the CANONICAL stack dir
|
||||
// (stacks/<envName>/<stackName>/) — where every managed stack lives — and
|
||||
// bring the stack up FROM THERE, so after a restore (even one that deleted
|
||||
// the stack completely) it is a normal managed stack on disk, not a
|
||||
// throwaway /tmp copy (#1329). writeLocalStackFiles carries the path-
|
||||
// traversal + data-loss + atomic-swap guards; overwrite=true because an
|
||||
// in-place restore is destructive by design.
|
||||
const stackDir = await getStackDir(name, envId ?? undefined);
|
||||
const destination = await loadDest(destId);
|
||||
log(`materialising snapshot stackfiles → canonical dir: ${stackDir} (compose: ${composeFileName})`);
|
||||
const wrote = await materialiseStackFiles(destination, snapId, name, stackDir, true);
|
||||
if (!wrote || !existsSync(join(stackDir, composeFileName))) {
|
||||
throw new Error(`could not materialise stack files for "${name}" into ${stackDir}; cannot redeploy`);
|
||||
}
|
||||
log(`materialised OK → ${stackDir}`);
|
||||
|
||||
log(`redeploying (docker compose up) from: ${stackDir}`);
|
||||
const r = await redeployStackFromDir(name, stackDir, composeFileName, envId ?? undefined);
|
||||
if (!r.success) throw new Error(r.error || 'docker compose up failed');
|
||||
log(`redeploy OK`);
|
||||
|
||||
// Register the restored stack as managed (internal) so the UI can
|
||||
// edit/redeploy it — a complete-delete-then-restore must come back managed.
|
||||
const composePath = join(stackDir, composeFileName);
|
||||
const envPath = existsSync(join(stackDir, '.env')) ? join(stackDir, '.env') : null;
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envId ?? null,
|
||||
sourceType: 'internal',
|
||||
composePath,
|
||||
envPath
|
||||
});
|
||||
log(`registered as managed (internal): composePath=${composePath} envPath=${envPath ?? '(none)'}`);
|
||||
},
|
||||
readSnapshotMetadata: (destId, snapId) => getSnapshotMetadata(destId, snapId),
|
||||
// Materialise the snapshot's captured stack files into Dockhand's LOCAL data
|
||||
@@ -347,88 +481,8 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
|
||||
// files were backed up under /metadata/stacks/<name>/stackfiles/; dump that
|
||||
// subtree as a tar and extract it flat into targetPath.
|
||||
writeLocalStackFiles: async (destId, snapId, stackName, targetPath, overwrite) => {
|
||||
const { mkdtempSync, mkdirSync, rmSync, existsSync, cpSync, readdirSync, renameSync } = await import('fs');
|
||||
const { tmpdir } = await import('os');
|
||||
const { join, dirname, resolve, sep } = await import('path');
|
||||
const { randomUUID } = await import('crypto');
|
||||
const { getStacksDir } = await import('../stacks');
|
||||
// Use `restic restore --include` (not dump→tar): restore writes a
|
||||
// DETERMINISTIC path under the target — <tmp>/metadata/stacks/<name>/
|
||||
// stackfiles/ — so there's no tar-prefix guessing. Mirrors the proven
|
||||
// approach the previous engine used (commit 5edcfa07 et al.).
|
||||
const destination = await loadDest(destId);
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'dh-stackfiles-'));
|
||||
try {
|
||||
const include = `/metadata/stacks/${stackName}/stackfiles`;
|
||||
const run = await restic.runLocal(destination, ['restore', snapId, '--target', tmp, '--include', include], 'data');
|
||||
if (run.exitCode !== 0) {
|
||||
console.log(`[Backup] writeLocalStackFiles: restic restore failed for "${stackName}": ${run.stderr.trim() || `exit ${run.exitCode}`}`);
|
||||
return false;
|
||||
}
|
||||
const extractedDir = join(tmp, 'metadata', 'stacks', stackName, 'stackfiles');
|
||||
// DATA-LOSS guard: only proceed if extraction actually produced files.
|
||||
// A snapshot that predates full-dir capture, or a corrupt path, yields
|
||||
// nothing — replacing the live dir with an empty one would be data loss.
|
||||
const extracted = existsSync(extractedDir) ? readdirSync(extractedDir) : [];
|
||||
if (extracted.length === 0) {
|
||||
console.log(`[Backup] writeLocalStackFiles: no stackfiles extracted for "${stackName}" (snapshot may predate full-dir capture) — leaving ${targetPath} untouched`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Path-traversal guard: the resolved target MUST stay under the stacks
|
||||
// root before any mkdir/rename/rm — a "../.." name must never let a
|
||||
// restore wipe an arbitrary directory.
|
||||
const resolvedTarget = resolve(targetPath);
|
||||
const stacksRoot = resolve(getStacksDir());
|
||||
if (resolvedTarget !== stacksRoot && !resolvedTarget.startsWith(stacksRoot + sep)) {
|
||||
console.log(`[Backup] writeLocalStackFiles: refusing target "${resolvedTarget}" outside the stacks root "${stacksRoot}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
mkdirSync(resolvedTarget, { recursive: true });
|
||||
|
||||
// Atomic staging-swap (audit #3 / HIGH #2): build the desired contents
|
||||
// in a sibling staging dir FIRST, then swap it into place with a
|
||||
// same-parent rename, so a mid-copy failure can never leave the live
|
||||
// stack dir half-wiped. On OVERWRITE the staging holds ONLY the restored
|
||||
// files (stale files cleared); on merge it starts as a copy of the live
|
||||
// dir so existing user files are preserved.
|
||||
const parent = dirname(resolvedTarget);
|
||||
const staging = mkdtempSync(join(parent, '.dockhand-stack-new-'));
|
||||
const oldAside = join(parent, `.dockhand-stack-old-${randomUUID()}`);
|
||||
try {
|
||||
if (!overwrite) {
|
||||
for (const existing of readdirSync(resolvedTarget)) {
|
||||
cpSync(join(resolvedTarget, existing), join(staging, existing), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const entry of extracted) {
|
||||
cpSync(join(extractedDir, entry), join(staging, entry), { recursive: true, force: true });
|
||||
}
|
||||
// Copy fully succeeded — same-parent swap: live aside, staging in.
|
||||
renameSync(resolvedTarget, oldAside);
|
||||
try {
|
||||
renameSync(staging, resolvedTarget);
|
||||
} catch (swapErr) {
|
||||
try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
|
||||
throw swapErr;
|
||||
}
|
||||
rmSync(oldAside, { recursive: true, force: true });
|
||||
console.log(`[Backup] writeLocalStackFiles: wrote ${extracted.length} entries to ${resolvedTarget} (${overwrite ? 'replaced' : 'merged'})`);
|
||||
return true;
|
||||
} catch (copyErr) {
|
||||
// Fail closed: clean up staging/aside, restore the live dir if the
|
||||
// swap left it moved, and rethrow so the restore reports FAILED.
|
||||
try { rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
if (existsSync(oldAside) && !existsSync(resolvedTarget)) {
|
||||
try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
|
||||
}
|
||||
try { rmSync(oldAside, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
throw copyErr;
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
return materialiseStackFiles(destination, snapId, stackName, targetPath, overwrite);
|
||||
},
|
||||
// --- clone (cross-env restore) -----------------------------------------------
|
||||
volumeExists: async (name, envId) => {
|
||||
@@ -499,7 +553,7 @@ export async function previewSnapshot(
|
||||
destinationId: number,
|
||||
snapshotId: string,
|
||||
access: { isEnterprise: boolean; canAccessEnvironment: (id: number) => Promise<boolean> } = { isEnterprise: false, canAccessEnvironment: async () => true },
|
||||
): Promise<{ snapshotId: string; volumes: string[]; volumeTypes: Record<string, 'volume' | 'bind'>; volumeSources: Record<string, string>; backupTime: string | null; sourceEnvironmentId: number | null; hasMetadata: boolean; hasStackFiles: boolean }> {
|
||||
): Promise<{ snapshotId: string; volumes: string[]; volumeTypes: Record<string, 'volume' | 'bind'>; volumeSources: Record<string, string>; backupTime: string | null; sourceEnvironmentId: number | null; hasMetadata: boolean; hasStackFiles: boolean; sourceSecretKeys: string[] }> {
|
||||
const destination = await getBackupDestination(destinationId);
|
||||
if (!destination) throw new Error('backup destination not found');
|
||||
const instanceId = await getInstanceId();
|
||||
@@ -518,6 +572,13 @@ export async function previewSnapshot(
|
||||
const volumeSources: Record<string, string> = {};
|
||||
let backupTime: string | null = null;
|
||||
let sourceEnvironmentId: number | null = null;
|
||||
// Names (never values) of the secret env vars carried IN the snapshot for this stack.
|
||||
// The restore UI lists these under the "restore secrets from this backup" toggle so
|
||||
// the operator sees exactly which secrets come back. The VALUES live in the snapshot
|
||||
// as ciphertext (never exposed here) and are written to the DB by redeployStack.
|
||||
let sourceSecretKeys: string[] = [];
|
||||
let mdType: string | null = null;
|
||||
let mdTargetName: string | null = null;
|
||||
if (hasMetadata) {
|
||||
const dump = await restic.runLocal(destination, ['dump', '--no-lock', snapshotId, '/metadata/metadata.json']);
|
||||
if (dump.exitCode === 0) {
|
||||
@@ -525,6 +586,13 @@ export async function previewSnapshot(
|
||||
const md = JSON.parse(dump.stdout);
|
||||
backupTime = md?.backupTime ?? null;
|
||||
sourceEnvironmentId = typeof md?.environmentId === 'number' ? md.environmentId : null;
|
||||
mdType = typeof md?.type === 'string' ? md.type : null;
|
||||
mdTargetName = typeof md?.targetName === 'string' ? md.targetName : null;
|
||||
if (Array.isArray(md?.secrets)) {
|
||||
sourceSecretKeys = md.secrets
|
||||
.map((s: any) => s?.key)
|
||||
.filter((k: unknown): k is string => typeof k === 'string');
|
||||
}
|
||||
for (const v of md?.volumes ?? []) {
|
||||
// Key by v.key — that's what listSnapshotVolumes returns (the /volumes/<key>
|
||||
// segment) and what the restore UI indexes on. For a bind, key !== name
|
||||
@@ -537,7 +605,7 @@ export async function previewSnapshot(
|
||||
} catch { /* leave types empty on malformed metadata */ }
|
||||
}
|
||||
}
|
||||
return { snapshotId, volumes, volumeTypes, volumeSources, backupTime, sourceEnvironmentId, hasMetadata, hasStackFiles };
|
||||
return { snapshotId, volumes, volumeTypes, volumeSources, backupTime, sourceEnvironmentId, hasMetadata, hasStackFiles, sourceSecretKeys };
|
||||
}
|
||||
|
||||
// --- snapshot reads (routes already apply the env guard; we add instance own.) ---
|
||||
@@ -702,25 +770,25 @@ export type RepoTask = 'unlock' | 'check' | 'prune' | 'stats' | 'repair-index' |
|
||||
/** Run a repo-maintenance task. Returns { success } for the route + activity log. */
|
||||
export interface RepoStats { totalSize: number; totalFiles: number; snapshots: number }
|
||||
|
||||
export async function runRepoTask(destinationId: number, task: RepoTask, opts?: { maxUnused?: string; dataSubset?: string }): Promise<{ success: boolean; output?: string; error?: string; stats?: RepoStats }> {
|
||||
export async function runRepoTask(destinationId: number, task: RepoTask, opts?: { maxUnused?: string; dataSubset?: string; staleOnly?: boolean }): Promise<{ success: boolean; output?: string; error?: string; stats?: RepoStats }> {
|
||||
const destination = await loadDest(destinationId);
|
||||
if (task === 'repair-index' || task === 'repair-snapshots') {
|
||||
const sub = task === 'repair-index' ? 'index' : 'snapshots';
|
||||
// repair takes the repo lock — serialize against a concurrent backup to the same repo.
|
||||
const run = await serializeByRepo(destinationId, () => restic.runLocal(destination, ['repair', sub], 'data'));
|
||||
// repair takes the repo lock — serialize against a concurrent backup to the same
|
||||
// repo, and --retry-lock so a legitimately-running cross-instance backup doesn't
|
||||
// make repair fail REPO_LOCKED (parity with prune/forget).
|
||||
const run = await serializeByRepo(destinationId, () => restic.runLocal(destination, ['repair', sub, '--retry-lock', '5m'], 'data'));
|
||||
logRepoOp(destination.name, `repair ${sub}`, run.exitCode === 0, { output: run.stdout, error: run.stderr });
|
||||
return run.exitCode === 0 ? { success: true, output: run.stdout.trim() } : { success: false, error: run.stderr.trim() || 'repair failed' };
|
||||
}
|
||||
// prune/check hold the repo lock; run them serialized on the destination so a
|
||||
// scheduled maintenance pass can't collide with a backup writing to the same repo.
|
||||
// unlock is ALSO serialized: it now uses `--remove-all` (see unlockRepository), so
|
||||
// running it unserialized could wipe the LIVE lock of a concurrent backup/restore on
|
||||
// the same repo in THIS instance. Serializing means the only locks it clears are
|
||||
// orphans (dead owners) — exactly its documented job. stats (--no-lock) is read-safe
|
||||
// and stays unserialized.
|
||||
// unlock: the EXPLICIT user action uses `--remove-all`; an AUTOMATIC caller passes
|
||||
// staleOnly (plain unlock) so it never wipes a live foreign lock on a shared repo.
|
||||
// stats (--no-lock) is read-safe and stays unserialized.
|
||||
const r = task === 'prune' ? await serializeByRepo(destinationId, () => pruneRepository(reader(), destination, opts?.maxUnused))
|
||||
: task === 'check' ? await serializeByRepo(destinationId, () => checkRepository(reader(), destination, opts?.dataSubset))
|
||||
: task === 'unlock' ? await serializeByRepo(destinationId, () => unlockRepository(reader(), destination))
|
||||
: task === 'unlock' ? await serializeByRepo(destinationId, () => unlockRepository(reader(), destination, !opts?.staleOnly))
|
||||
: await repoStats(reader(), destination);
|
||||
logRepoOp(destination.name, task, r.ok, r.ok ? { output: r.output } : { error: r.error });
|
||||
if (!r.ok) return { success: false, error: r.error };
|
||||
@@ -924,18 +992,6 @@ async function runBackupRegistered(configId: number, triggeredBy: 'cron' | 'manu
|
||||
|
||||
/** Run a restore. The route layer has already validated the request + auth and
|
||||
* supplies the caller's access context for the ownership guard. */
|
||||
/** A filesystem repo (absolute or relative path) rather than a remote backend. */
|
||||
function isLocalRepo(repository: string): boolean {
|
||||
return repository.startsWith('/') || repository.startsWith('./');
|
||||
}
|
||||
/** An environment whose Docker daemon is NOT Dockhand's own host. */
|
||||
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
|
||||
if (!connectionType) return false;
|
||||
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
|
||||
if (connectionType === 'direct' && !!host) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runRestore(
|
||||
job: RestoreJob,
|
||||
access: { isEnterprise: boolean; canAccessEnvironment: (id: number) => Promise<boolean> } = { isEnterprise: false, canAccessEnvironment: async () => true },
|
||||
@@ -944,18 +1000,9 @@ export async function runRestore(
|
||||
const destination = await getBackupDestination(job.destinationId);
|
||||
if (!destination) return { status: 'error', code: 'VALIDATION', error: 'backup destination not found' };
|
||||
|
||||
// A local filesystem repository lives on Dockhand's own host and is bind-mounted
|
||||
// into the restore helper. That only works when the helper runs on the same host
|
||||
// (socket env); on a remote environment the repo path doesn't exist, so the
|
||||
// restore would fail deep in restic. Reject it up front with a clear message —
|
||||
// mirrors the same guard on the backup-config path.
|
||||
if (job.environmentId != null && isLocalRepo(destination.repository)) {
|
||||
const env = await getEnvironment(job.environmentId);
|
||||
if (env && isRemoteEnv(env.connectionType, env.host)) {
|
||||
return { status: 'error', code: 'VALIDATION', error: `Local repository "${destination.name}" cannot restore to remote environment "${env.name}" — the repository path only exists on Dockhand's host. Use an S3 or REST destination for cross-host restores.` };
|
||||
}
|
||||
}
|
||||
|
||||
// A local-path repo is allowed on any env: the restore helper (on the target
|
||||
// daemon) fails loud via the localRepoGuard if the repo isn't visible there
|
||||
// (wrong-host mount), so no silent bad restore — see restic-script.ts.
|
||||
return new RestoreService(restorePorts(destination, access, onProgress)).run(job, 'manual');
|
||||
}
|
||||
|
||||
|
||||
@@ -84,19 +84,24 @@ export async function pruneRepository(restic: ResticLocal, destination: any, max
|
||||
return toResult(run);
|
||||
}
|
||||
|
||||
/** Remove stale repository locks left by a crashed operation. */
|
||||
export async function unlockRepository(restic: ResticLocal, destination: any): Promise<RepoResult> {
|
||||
// `--remove-all`, not a plain `unlock`. This is an EXPLICIT, user-initiated action
|
||||
// ("Unlock" in the UI / `task:unlock`), whose documented purpose is to "remove stale
|
||||
// locks left behind when a previous restic operation crashed or was killed" and is
|
||||
// "safe if no other operation is currently running against the repo". A plain
|
||||
// `restic unlock` only removes locks it can prove stale, and it CANNOT prove that for
|
||||
// a lock whose hostname belongs to a now-gone helper CONTAINER (restic:
|
||||
// `if hn != l.Hostname { return false }`, then a 30-min age-out) — i.e. exactly the
|
||||
// orphaned lock this button exists to clear. So the plain form silently failed to do
|
||||
// what the UI/manual promise. `--remove-all` makes it actually clear those locks.
|
||||
// Only reachable via a deliberate user/API call, never automatic retention.
|
||||
const run = await restic.runLocal(destination, ['unlock', '--remove-all']);
|
||||
/**
|
||||
* Remove repository locks left by a crashed operation.
|
||||
*
|
||||
* `removeAll` (default true) uses `restic unlock --remove-all` — clears EVERY lock,
|
||||
* including one it cannot prove stale (an orphan whose helper-container hostname restic
|
||||
* won't age out for 30 min). That is what the EXPLICIT user "Unlock" button needs, and
|
||||
* it is safe only when no other op runs against the repo — the operator's call.
|
||||
*
|
||||
* `removeAll = false` is a plain `restic unlock`: it reaps ONLY locks restic can prove
|
||||
* stale and LEAVES a live foreign lock intact. AUTOMATIC callers (scheduled maintenance
|
||||
* auto-unlock) MUST use this — the per-repo serializer is per-instance and cannot see a
|
||||
* separate instance's live lock on a shared repo, so a blind --remove-all there would
|
||||
* silently wipe another instance's in-flight backup lock (same hazard backup-service
|
||||
* deliberately avoids). #1313-adjacent shared-repo data-safety fix.
|
||||
*/
|
||||
export async function unlockRepository(restic: ResticLocal, destination: any, removeAll = true): Promise<RepoResult> {
|
||||
const args = removeAll ? ['unlock', '--remove-all'] : ['unlock'];
|
||||
const run = await restic.runLocal(destination, args);
|
||||
return toResult(run);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* backups/restic-script.ts — pure helpers for building the shell command a
|
||||
* restic helper container runs, and reading its exit code back. No I/O, no DB,
|
||||
* no docker — so these are unit-testable in isolation (the Restic class in
|
||||
* ./restic that uses them is the side-effecting edge).
|
||||
*
|
||||
* Why an exit marker instead of relying on the container's exit code: restic's
|
||||
* `backup` exit code 3 ("could not read all source files") is a partial-but-
|
||||
* valid outcome we must be able to see as a warning rather than a hard failure.
|
||||
* The helper script captures restic's real exit code and prints it as its final
|
||||
* stdout line; the caller reads it back. The container's own shell always exits
|
||||
* 0 so the docker layer doesn't treat a mere restic non-zero as a helper crash.
|
||||
* Pure builders for the restic helper container's shell command + reading its
|
||||
* exit code back. The exit marker preserves restic's real code (esp. exit 3 =
|
||||
* partial backup, a warning not a failure) since the helper's shell always
|
||||
* exits 0 so docker doesn't treat a restic non-zero as a crash.
|
||||
*/
|
||||
|
||||
/** The marker line the helper prints carrying restic's real exit code. */
|
||||
@@ -26,26 +19,47 @@ export function resticCommand(args: string[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a command so it runs, captures its exit code, and prints the exit marker
|
||||
* as the FINAL stdout line — regardless of success or failure. The command runs
|
||||
* WITHOUT `set -e` so a restic non-zero (e.g. exit 3) doesn't abort before the
|
||||
* marker is printed.
|
||||
*
|
||||
* CONSTRAINT: `command` must RETURN its status (which restic and `sh -c '...'`
|
||||
* do), not terminate the shell with a bare `exit N` — a bare exit ends the whole
|
||||
* script before the marker line runs, and the caller would then read `undefined`
|
||||
* (treated as an unknown-outcome failure). When composing a multi-step session
|
||||
* script, wrap a step whose failure must be caught in a subshell `( ... )` or
|
||||
* `sh -c '...'` rather than letting `set -e` / `exit` end the wrapper.
|
||||
* Append the exit marker after `command` so the caller reads its real code.
|
||||
* GOTCHA: `command` must RETURN its status, not `exit N` - a bare exit ends the
|
||||
* script before the marker prints, and the caller reads `undefined` (= failure).
|
||||
* Wrap a step that might `exit`/`set -e` in a subshell `( ... )`.
|
||||
*/
|
||||
export function finishScript(command: string): string {
|
||||
return `${command}; __rc=$?; echo "${EXIT_MARKER}\${__rc}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail loud if a bind-mounted local repo isn't visible on the target daemon's
|
||||
* host: Docker would auto-create the missing path empty, silently backing up to
|
||||
* the wrong host. '' for non-local repos (they never bind a path).
|
||||
*/
|
||||
export function localRepoGuard(repository: string): string {
|
||||
if (!(repository.startsWith('/') || repository.startsWith('./'))) return '';
|
||||
const msg = `restic repository not found at $RESTIC_REPOSITORY on this environment's Docker host. A local-path repository only works when the environment's Docker daemon runs on the same host as Dockhand (e.g. a co-located socket-proxy). For a remote host, use an S3 or REST destination.`;
|
||||
return `test -f "$RESTIC_REPOSITORY/config" || { echo ${shellQuote(msg)} >&2; exit 1; }; `;
|
||||
}
|
||||
|
||||
/**
|
||||
* The helper runs as root (it must read root-owned source volumes), so it writes
|
||||
* the repo files as root. The main Dockhand process reads the repo as its own
|
||||
* (su-exec'd) uid, so for a LOCAL-path repo it couldn't read root-owned files
|
||||
* ("snapshot not found"). Re-own the repo to the main process's uid:gid after the
|
||||
* op so subsequent local reads work. Best-effort (`|| true`) and appended AFTER the
|
||||
* exit marker, so it never changes restic's real exit code. '' for non-local repos
|
||||
* (no bind, nothing to chown) and when ownerSpec is empty.
|
||||
* Note: this only re-owns the repo FILES; the ownership of the BACKED-UP data lives
|
||||
* in snapshot metadata and is untouched (restore fidelity is preserved).
|
||||
*/
|
||||
export function localRepoChown(repository: string, ownerSpec: string): string {
|
||||
if (!ownerSpec) return '';
|
||||
if (!(repository.startsWith('/') || repository.startsWith('./'))) return '';
|
||||
return `; chown -R ${ownerSpec} "$RESTIC_REPOSITORY" 2>/dev/null || true`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the restic exit code from a helper's stdout (the EXIT_MARKER line, read
|
||||
* from the end so trailing output wins). Returns undefined if the marker is
|
||||
* absent — meaning the script did not run to completion (killed / crashed),
|
||||
* absent - meaning the script did not run to completion (killed / crashed),
|
||||
* i.e. an unknown outcome the caller must treat as failure.
|
||||
*/
|
||||
export function readExitMarker(stdout: string): number | undefined {
|
||||
@@ -60,6 +74,35 @@ export function readExitMarker(stdout: string): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a spawned restic process's `close` event into the fail-closed exit/stderr
|
||||
* contract, independent of how stdout was captured (string or Buffer). A timeout kill
|
||||
* gives `code === null` + a signal, which maps to `exitCode: undefined` (unknown
|
||||
* outcome = failure for the caller), and the killing signal is appended to stderr so
|
||||
* it is visible in diagnostics.
|
||||
*/
|
||||
export function classifyProcClose(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
stderr: string
|
||||
): { exitCode: number | undefined; stderr: string } {
|
||||
const exitCode = code === null ? undefined : code;
|
||||
const stderrOut = signal ? `${stderr}\n(process killed by ${signal})` : stderr;
|
||||
return { exitCode, stderr: stderrOut };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a spawn `error` (restic missing / not spawnable): a real failure surfaced
|
||||
* as data with `exitCode: undefined` (never thrown), the error message appended to
|
||||
* stderr so the caller sees why.
|
||||
*/
|
||||
export function classifyProcError(
|
||||
err: Error,
|
||||
stderr: string
|
||||
): { exitCode: undefined; stderr: string } {
|
||||
return { exitCode: undefined, stderr: `${stderr}\n${err.message}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the KEY=value env array for a restic helper container: repo + password
|
||||
* + a progress-rate hint, plus the cloud credentials that survive the allowlist.
|
||||
@@ -81,8 +124,8 @@ export function buildHelperEnv(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bind list for a restic helper: the caller's volume binds, plus — for
|
||||
* a local (filesystem) repository only — the repo path bind so restic can reach
|
||||
* Build the bind list for a restic helper: the caller's volume binds, plus - for
|
||||
* a local (filesystem) repository only - the repo path bind so restic can reach
|
||||
* it. `resolveLocalRepoHostPath` maps the in-container repo path to a host path.
|
||||
*/
|
||||
export function buildHelperBinds(
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from './security';
|
||||
import { TIMEOUTS, type ResticRun, type TimeoutTier } from './models';
|
||||
import { withTimeout } from './helpers';
|
||||
import { resticCommand, finishScript, readExitMarker, buildHelperEnv, buildHelperBinds } from './restic-script';
|
||||
import { resticCommand, finishScript, readExitMarker, buildHelperEnv, buildHelperBinds, localRepoGuard, localRepoChown, classifyProcClose, classifyProcError } from './restic-script';
|
||||
import { translateContainerPathViaMount } from '../host-path';
|
||||
|
||||
/** The label key stamped on every helper container so a reaper only removes
|
||||
@@ -119,15 +119,13 @@ export class Restic {
|
||||
proc.stdout.on('data', (d) => { stdout += d; });
|
||||
proc.stderr.on('data', (d) => { stderr += d; });
|
||||
proc.on('close', (code, signal) => {
|
||||
// A timeout kill gives code === null + a signal → exit is undefined
|
||||
// (unknown outcome), which the caller treats as failure.
|
||||
const exitCode = code === null ? undefined : code;
|
||||
const stderrOut = signal ? `${stderr}\n(process killed by ${signal})` : stderr;
|
||||
const { exitCode, stderr: stderrOut } = classifyProcClose(code, signal, stderr);
|
||||
done({ exitCode, stdout, stderr: stderrOut });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
// restic missing / not spawnable — a real failure, surfaced as data.
|
||||
done({ exitCode: undefined, stdout, stderr: `${stderr}\n${err.message}` });
|
||||
const { exitCode, stderr: stderrOut } = classifyProcError(err, stderr);
|
||||
done({ exitCode, stdout, stderr: stderrOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -163,12 +161,12 @@ export class Restic {
|
||||
proc.stdout.on('data', (d: Buffer) => { chunks.push(Buffer.from(d)); });
|
||||
proc.stderr.on('data', (d) => { stderr += d; });
|
||||
proc.on('close', (code, signal) => {
|
||||
const exitCode = code === null ? undefined : code;
|
||||
const stderrOut = signal ? `${stderr}\n(process killed by ${signal})` : stderr;
|
||||
const { exitCode, stderr: stderrOut } = classifyProcClose(code, signal, stderr);
|
||||
done({ exitCode, stdout: Buffer.concat(chunks), stderr: stderrOut });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
done({ exitCode: undefined, stdout: Buffer.concat(chunks), stderr: `${stderr}\n${err.message}` });
|
||||
const { exitCode, stderr: stderrOut } = classifyProcError(err, stderr);
|
||||
done({ exitCode, stdout: Buffer.concat(chunks), stderr: stderrOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -194,9 +192,18 @@ export class Restic {
|
||||
// code. A caller-supplied script (e.g. the in-place restore swap) may use
|
||||
// `set -e`, so capture its exit in a subshell — the marker line must still
|
||||
// print on success, otherwise a clean run reads back as undefined (failure).
|
||||
// The local-repo guard runs FIRST (inside the marked command, so its exit 1
|
||||
// surfaces via the marker): a local repo whose `config` isn't visible on the
|
||||
// target daemon's host is a wrong-host mount — fail loud, not silently empty.
|
||||
const guard = localRepoGuard(destination.repository);
|
||||
// The helper runs as root; re-own a local repo to the main process's uid:gid
|
||||
// after the op so the main process (su-exec'd to that uid) can read it back.
|
||||
// Appended AFTER the exit marker so it never changes restic's exit code.
|
||||
const ownerSpec = `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`;
|
||||
const chown = localRepoChown(destination.repository, ownerSpec);
|
||||
const cmd = spec.script
|
||||
? ['sh', '-c', finishScript(`( ${spec.script} )`)]
|
||||
: ['sh', '-c', finishScript(resticCommand([...spec.args, ...sanitizeResticFlags(destination.flags)]))];
|
||||
? ['sh', '-c', finishScript(`( ${guard}${spec.script} )`) + chown]
|
||||
: ['sh', '-c', finishScript(`${guard}${resticCommand([...spec.args, ...sanitizeResticFlags(destination.flags)])}`) + chown];
|
||||
|
||||
// Large metadata/stack files go into the container via put-archive (docker
|
||||
// cp), NOT the Cmd, so they can't blow ARG_MAX. Build the tar once; stream
|
||||
|
||||
@@ -39,7 +39,7 @@ async function runMaintenanceTask(
|
||||
const policies = parsePoliciesJson(dest.policies); // (audit #26/#37) safe-degrade + log on malformed
|
||||
if (policies.autoUnlock) {
|
||||
log('Auto-unlocking repository...');
|
||||
await runRepoTask(destinationId, 'unlock');
|
||||
await runRepoTask(destinationId, 'unlock', { staleOnly: true }); // plain unlock: never wipe a live foreign lock on a shared repo
|
||||
}
|
||||
|
||||
log(`Running ${task}...`);
|
||||
@@ -122,7 +122,7 @@ export async function runRepoVerify(
|
||||
const policies = parsePoliciesJson(dest.policies); // (audit #26/#37) safe-degrade + log on malformed
|
||||
if (policies.autoUnlock) {
|
||||
log('Auto-unlocking repository...');
|
||||
await runRepoTask(destinationId, 'unlock');
|
||||
await runRepoTask(destinationId, 'unlock', { staleOnly: true }); // plain unlock: never wipe a live foreign lock on a shared repo
|
||||
}
|
||||
|
||||
log(`Verifying ${dataSubset} of data...`);
|
||||
|
||||
@@ -14,9 +14,23 @@ export function isLocalRepo(repository: string): boolean {
|
||||
}
|
||||
|
||||
/** An environment reachable over the network (hawser or direct-with-host). */
|
||||
export function isRemoteEnvironment(env?: { connectionType?: string | null; host?: string | null }): boolean {
|
||||
export function isRemoteEnvironment(env?: { connectionType?: string | null; host?: string | null } | null): boolean {
|
||||
if (!env) return false;
|
||||
if (env.connectionType === 'hawser-standard' || env.connectionType === 'hawser-edge') return true;
|
||||
if (env.connectionType === 'direct' && !!env.host) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory (NOT a block): a local-path repo on a remote/direct env only works
|
||||
* when that env's Docker daemon shares Dockhand's host (e.g. a co-located
|
||||
* socket-proxy). The backup/restore helper runs on the target daemon and fails
|
||||
* loud if the repo isn't visible there (see restic-script.ts localRepoGuard), so
|
||||
* this is only a UI hint — the operation itself is allowed and self-checks.
|
||||
*/
|
||||
export function localRepoNeedsSameHost(
|
||||
dest: { repository: string },
|
||||
env?: { connectionType?: string | null; host?: string | null } | null
|
||||
): boolean {
|
||||
return isLocalRepo(dest.repository) && isRemoteEnvironment(env);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function getRepoTypeLabel(repository: string): string {
|
||||
|
||||
// Repository/environment predicates live in the lucide-free shared module so
|
||||
// server routes can import them without pulling in this file's icon deps (#37).
|
||||
export { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
|
||||
export { isLocalRepo, isRemoteEnvironment, localRepoNeedsSameHost } from '$lib/shared/repo-predicates';
|
||||
export {
|
||||
normalizeMounts,
|
||||
normalizeStackMounts,
|
||||
|
||||
@@ -4,26 +4,12 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import {
|
||||
getBackupConfigs,
|
||||
createBackupConfig,
|
||||
getBackupDestination,
|
||||
getEnvironment
|
||||
createBackupConfig
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, isValidCron } from '$lib/server/scheduler';
|
||||
import { assertStackBackupable } from '$lib/server/backups';
|
||||
import { validateRetention, retentionToStore } from '$lib/server/backups/helpers';
|
||||
|
||||
// Inlined to avoid pulling lucide-svelte / svelte runtime into the server bundle
|
||||
// via $lib/utils/backup. Keep in sync with that module if the predicates change.
|
||||
function isLocalRepo(repository: string): boolean {
|
||||
return repository.startsWith('/') || repository.startsWith('./');
|
||||
}
|
||||
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
|
||||
if (!connectionType) return false;
|
||||
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
|
||||
if (connectionType === 'direct' && !!host) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
@@ -92,20 +78,8 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Refuse local repo + remote env: the restic helper runs on the REMOTE
|
||||
// daemon, which has no access to Dockhand's filesystem. The backup would
|
||||
// silently write to a wrong path on the remote host.
|
||||
if (body.environmentId) {
|
||||
const [dest, env] = await Promise.all([
|
||||
getBackupDestination(body.destinationId),
|
||||
getEnvironment(body.environmentId)
|
||||
]);
|
||||
if (dest && env && isRemoteEnv(env.connectionType, env.host) && isLocalRepo(dest.repository)) {
|
||||
return json({
|
||||
error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
// A local-path repo is allowed on any env; if the target daemon can't see the
|
||||
// repo (wrong-host mount) the backup fails loud via the helper's localRepoGuard.
|
||||
|
||||
// External stacks (no known compose path) can't be backed up usefully —
|
||||
// see assertStackBackupable for the why.
|
||||
|
||||
@@ -4,15 +4,12 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import {
|
||||
updateBackupConfig,
|
||||
deleteBackupConfig,
|
||||
getBackupDestination,
|
||||
getEnvironment
|
||||
deleteBackupConfig
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule, isValidCron } from '$lib/server/scheduler';
|
||||
import { isBackupRunning } from '$lib/server/backups';
|
||||
import { validateRetention, retentionToStore, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
import { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -59,21 +56,8 @@ export const PUT: RequestHandler = async (event) => {
|
||||
return json({ error: 'Cannot change the destination while a backup is running for this config' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Refuse local repo + remote env (same rule as POST). Effective destination
|
||||
// is the body's value if supplied, otherwise the existing config's.
|
||||
const effectiveDestId = body.destinationId ?? existing.destinationId;
|
||||
const effectiveEnvId = existing.environmentId; // env is fixed at creation
|
||||
if (effectiveEnvId) {
|
||||
const [dest, env] = await Promise.all([
|
||||
getBackupDestination(effectiveDestId),
|
||||
getEnvironment(effectiveEnvId)
|
||||
]);
|
||||
if (dest && env && isRemoteEnvironment(env) && isLocalRepo(dest.repository)) {
|
||||
return json({
|
||||
error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
// A local-path repo is allowed on any env; a wrong-host mount fails loud via
|
||||
// the helper's localRepoGuard at run time.
|
||||
|
||||
// Auto-enable a config that transitions from manual (no schedule) to scheduled
|
||||
// (a real cron) — otherwise a paused run-once config that the user edits to add a
|
||||
|
||||
@@ -7,25 +7,12 @@ import {
|
||||
updateBackupDestination,
|
||||
deleteBackupDestination,
|
||||
decryptBackupDestination,
|
||||
getBackupConfigs,
|
||||
getEnvironment
|
||||
getBackupConfigs
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers';
|
||||
import { destinationHasRunningBackup } from '$lib/server/backups';
|
||||
|
||||
// Inlined predicates (kept in sync with the config routes) — see
|
||||
// configs/+server.ts. A local repo cannot back up a remote environment.
|
||||
function isLocalRepo(repository: string): boolean {
|
||||
return repository.startsWith('/') || repository.startsWith('./');
|
||||
}
|
||||
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
|
||||
if (!connectionType) return false;
|
||||
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
|
||||
if (connectionType === 'direct' && !!host) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-destination response shape. envVars (decrypted cloud credentials) are
|
||||
* ONLY included for callers with backups:manage — the edit form that pre-fills
|
||||
@@ -98,23 +85,8 @@ export const PUT: RequestHandler = async (event) => {
|
||||
const flagError = validateFlags(body.flags);
|
||||
if (flagError) return json({ error: flagError }, { status: 400 });
|
||||
|
||||
// (audit #8) Changing the repository to a LOCAL path bypasses the config-route
|
||||
// guard: refuse if any config on this destination targets a remote environment
|
||||
// (the restic helper runs on the remote daemon, which can't see Dockhand's fs).
|
||||
const effectiveRepository = body.repository !== undefined ? body.repository : existing.repository;
|
||||
if (body.repository !== undefined && isLocalRepo(effectiveRepository)) {
|
||||
const configs = await getBackupConfigs();
|
||||
const envIds = [...new Set(
|
||||
configs.filter((c: any) => c.destinationId === id && c.environmentId).map((c: any) => c.environmentId as number)
|
||||
)];
|
||||
const envs = await Promise.all(envIds.map((eid) => getEnvironment(eid)));
|
||||
const remote = envs.find((env) => env && isRemoteEnv(env.connectionType, env.host));
|
||||
if (remote) {
|
||||
return json({
|
||||
error: `Local repository cannot back up containers on remote environment "${remote.name}". Use S3, REST, or another non-local backend.`
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
// A local-path repo is allowed on any env; a wrong-host mount fails loud via
|
||||
// the helper's localRepoGuard at backup/restore time.
|
||||
|
||||
try {
|
||||
const updated = await updateBackupDestination(id, {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import { Package, Box, Layers, Search, Loader2, CheckCircle2, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, type BackupFormState } from '$lib/utils/backup';
|
||||
import { getRepoTypeIcon, formatCron, runBackupAction, type BackupFormState } from '$lib/utils/backup';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
|
||||
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
|
||||
@@ -27,7 +27,7 @@
|
||||
let { open = $bindable(), onCreated, onRun }: Props = $props();
|
||||
|
||||
interface Environment { id: number; name: string; icon?: string; connectionType?: string; host?: string | null; }
|
||||
interface Destination { id: number; name: string; repository: string; }
|
||||
interface Destination { id: number; name: string; repository: string; hostPath?: string | null; }
|
||||
interface ContainerItem { name: string; type: 'container' | 'stack'; envId: number; envName: string; envIcon?: string; volumes: VolumeInfo[]; }
|
||||
|
||||
let step = $state<1 | 2 | 3>(1);
|
||||
@@ -176,11 +176,9 @@
|
||||
|
||||
const getDestIcon = getRepoTypeIcon;
|
||||
const selectedDest = $derived(destinations.find(d => d.id === selectedDestId));
|
||||
// Used by the DestinationPicker to disable local-path destinations when
|
||||
// the chosen environment is remote (helper container on the remote host
|
||||
// can't read Dockhand's local filesystem).
|
||||
// Passed to DestinationPicker so it can hint that a local-path repo needs the
|
||||
// env's daemon co-located with Dockhand (advisory only, not a block).
|
||||
const selectedEnv = $derived(environments.find((e) => e.id === selectedItem?.envId));
|
||||
const isSelectedEnvRemote = $derived(isRemoteEnvironment(selectedEnv));
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && saving) return; open = isOpen; }}>
|
||||
@@ -306,7 +304,7 @@
|
||||
<DestinationPicker
|
||||
destinations={destinations}
|
||||
bind:value={selectedDestId}
|
||||
disableLocalForRemoteEnv={isSelectedEnvRemote}
|
||||
env={selectedEnv}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { getRepoTypeIcon, isLocalRepo, isRemoteEnvironment, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
|
||||
import { getRepoTypeIcon, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
|
||||
|
||||
interface Props {
|
||||
containerName: string;
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
let { containerName, volumes, type = 'container', environmentId, onConfigSaved, autoShowRunForm = false, onTally }: Props = $props();
|
||||
|
||||
interface Destination { id: number; name: string; repository: string; }
|
||||
interface Destination { id: number; name: string; repository: string; hostPath?: string | null; }
|
||||
interface BackupConfig {
|
||||
id: number; destinationId: number; enabled: boolean; schedule: string;
|
||||
retention: string | null; options?: string | null; stopBeforeBackup: boolean; selectedVolumes: string[] | null;
|
||||
@@ -154,7 +154,9 @@
|
||||
const progressEnv = $derived($environments.find((e) => e.id === envId));
|
||||
|
||||
const envId = $derived(environmentId ?? $currentEnvironment?.id ?? null);
|
||||
const isCurrentEnvRemote = $derived(() => isRemoteEnvironment($currentEnvironment));
|
||||
// The full env carries connectionType/host (currentEnvironment does not), needed
|
||||
// for the local-repo guard.
|
||||
const fullEnv = $derived($environments.find((e) => e.id === envId));
|
||||
|
||||
async function fetchDestinations() {
|
||||
try {
|
||||
@@ -327,11 +329,8 @@
|
||||
*/
|
||||
async function submitForm(action: BackupAction) {
|
||||
if (!editDestinationId) { toast.error('Select a backup repository'); return; }
|
||||
const selectedDest = destinations.find(d => d.id === editDestinationId);
|
||||
if (selectedDest && isCurrentEnvRemote() && isLocalRepo(selectedDest.repository)) {
|
||||
toast.error('Local repositories cannot be used with remote environments');
|
||||
return;
|
||||
}
|
||||
// A local repo on a non-co-located env is allowed here; it fails loud at run
|
||||
// time via the helper's localRepoGuard rather than being blocked up front.
|
||||
// Cron only matters when the schedule will be persisted. run-once
|
||||
// clears it server-side so the field value is irrelevant.
|
||||
if ((action === 'save' || action === 'save-run') && editScheduleInvalid) {
|
||||
@@ -596,7 +595,7 @@
|
||||
<DestinationPicker
|
||||
destinations={destinations}
|
||||
bind:value={editDestinationId}
|
||||
disableLocalForRemoteEnv={isCurrentEnvRemote()}
|
||||
env={fullEnv}
|
||||
triggerClass="h-9 w-[220px] flex-shrink-0"
|
||||
/>
|
||||
<CronEditor bind:value={editSchedule} bind:invalid={editScheduleInvalid} />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, ChevronDown, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
|
||||
import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
@@ -20,8 +20,10 @@
|
||||
id: number;
|
||||
name: string;
|
||||
repository: string;
|
||||
hostPath?: string | null;
|
||||
envVars?: Record<string, string>;
|
||||
flags?: string;
|
||||
policies?: string | null;
|
||||
lastTestStatus?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -122,7 +124,7 @@
|
||||
fields: [
|
||||
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
|
||||
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
|
||||
{ key: 'accountId', label: 'Account ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
|
||||
{ key: 'accountId', label: 'Key ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
|
||||
{ key: 'accountKey', label: 'Application key', placeholder: '', secret: true, envKey: 'B2_ACCOUNT_KEY' }
|
||||
],
|
||||
buildRepo: (f) => `b2:${f.bucket}:${f.path || ''}`.replace(/:$/, ''),
|
||||
@@ -191,6 +193,11 @@
|
||||
let policyAutoUnlock = $state(true);
|
||||
let testing = $state(false);
|
||||
let initializing = $state(false);
|
||||
// Set when a Test on a NEW (unsaved) destination reports the repo reachable but
|
||||
// not yet initialised. It flips the create button to "Save and init" so the user
|
||||
// can save + init in one click. Reset whenever the repo fields change (a stale
|
||||
// verdict must not drive the button after the user edits the target).
|
||||
let needsInit = $state(false);
|
||||
|
||||
const selectedBackend = $derived(backendTypes.find(b => b.value === formBackendType) ?? backendTypes[0]);
|
||||
const repoFields = $derived(selectedBackend.fields.filter(f => !f.envKey));
|
||||
@@ -305,8 +312,12 @@
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.needsInit) {
|
||||
// Only the create flow gets the "Save and init" button; editing an
|
||||
// existing destination already has its own "Init repo" action.
|
||||
needsInit = !isEditing;
|
||||
toast.info(data.message || 'Connection works, but repository needs initialization');
|
||||
} else {
|
||||
needsInit = false;
|
||||
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Connection test successful' : (data.error || 'Connection test failed'));
|
||||
}
|
||||
} catch { toast.error('Connection test failed'); } finally { testing = false; }
|
||||
@@ -342,6 +353,12 @@
|
||||
currentRepo; // track
|
||||
repoConflictName = null;
|
||||
});
|
||||
// Any change to the target (repo string OR credentials OR password) invalidates a
|
||||
// prior Test verdict, so the "Save and init" state must not linger.
|
||||
$effect(() => {
|
||||
currentRepo; formFields; formPassword; // track
|
||||
needsInit = false;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!formName.trim()) { formError = 'Name is required'; return; }
|
||||
@@ -389,7 +406,10 @@
|
||||
const res = await fetch(isEditing ? `/api/backup/destinations/${destination!.id}` : '/api/backup/destinations', {
|
||||
method: isEditing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) { open = false; onSaved(); toast.success(isEditing ? 'Destination updated' : 'Destination created'); }
|
||||
// Creating a destination auto-initialises its repository server-side, so a
|
||||
// create over a reachable-but-uninitialised repo (needsInit) needs no extra
|
||||
// call here — the button just tells the user that up front ("Save and init").
|
||||
if (res.ok) { open = false; onSaved(); toast.success(needsInit ? 'Destination created and repository initialized' : isEditing ? 'Destination updated' : 'Destination created'); }
|
||||
else { const data = await res.json(); formError = data.error || 'Failed'; }
|
||||
} catch { formError = 'Failed'; } finally { formSaving = false; }
|
||||
}
|
||||
@@ -408,7 +428,7 @@
|
||||
{#if selectedBackend.value === 'local'}
|
||||
<div class="flex items-start gap-2 p-2.5 mt-4 rounded-md bg-amber-500/10 border border-amber-500/20 text-xs text-amber-600 dark:text-amber-400">
|
||||
<HardDrive class="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||
<span>Local path repositories can only be used to back up and restore containers running on the same Docker host as Dockhand. For remote environments or cross-env restore, use S3, REST, or another remote backend.</span>
|
||||
<span>Local path repositories work on the local Docker host, or a co-located socket-proxy on that same host. For remote hosts, use S3, REST, or another remote backend.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-6 py-4">
|
||||
@@ -508,6 +528,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Policies section -->
|
||||
<div class="border-t pt-3 mt-2 space-y-3">
|
||||
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Repository policies</span>
|
||||
@@ -657,7 +678,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<Dialog.Footer class="flex-shrink-0 border-t mt-auto pt-4">
|
||||
<div class="flex gap-2 mr-auto">
|
||||
<div class="flex items-center gap-2 mr-auto">
|
||||
<Button variant="outline" size="sm" onclick={testConnection} disabled={testing}>
|
||||
{#if testing}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else}<Wifi class="w-4 h-4 mr-1" />{/if}
|
||||
Test
|
||||
@@ -667,12 +688,14 @@
|
||||
{#if initializing}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else}<Database class="w-4 h-4 mr-1" />{/if}
|
||||
Init repo
|
||||
</Button>
|
||||
{:else if needsInit}
|
||||
<span class="text-xs text-muted-foreground">Connection OK — repository will be initialized on save.</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => { open = false; onClose(); }}>Cancel</Button>
|
||||
<Button onclick={save} disabled={formSaving} variant={repoConflictName ? 'destructive' : 'default'}>
|
||||
{#if formSaving}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else if repoConflictName}<AlertTriangle class="w-4 h-4 mr-1" />{:else if isEditing}<Check class="w-4 h-4" />{:else}<Plus class="w-4 h-4" />{/if}
|
||||
{repoConflictName ? 'Save anyway' : isEditing ? 'Save' : 'Create'}
|
||||
{#if formSaving}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else if repoConflictName}<AlertTriangle class="w-4 h-4 mr-1" />{:else if needsInit && !isEditing}<Database class="w-4 h-4 mr-1" />{:else if isEditing}<Check class="w-4 h-4" />{:else}<Plus class="w-4 h-4" />{/if}
|
||||
{repoConflictName ? 'Save anyway' : needsInit && !isEditing ? 'Create and init' : isEditing ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import { Box, Layers, Loader2, Plus, ChevronDown, ChevronRight, ArrowRight, Zap, GitBranch } from 'lucide-svelte';
|
||||
import { formatCron, getRepoTypeIcon, isLocalRepo, isRemoteEnvironment } from '$lib/utils/backup';
|
||||
import { formatCron, getRepoTypeIcon } from '$lib/utils/backup';
|
||||
import { standaloneContainers, volumesForStack, type BackupItem } from '$lib/utils/mounts';
|
||||
import BackupPanel from '../../containers/BackupPanel.svelte';
|
||||
|
||||
@@ -36,8 +36,9 @@
|
||||
let batchDestId = $state<number>(0);
|
||||
let batchSchedule = $state('0 2 * * *');
|
||||
let batchSaving = $state(false);
|
||||
const isRemote = $derived(isRemoteEnvironment({ connectionType, host }));
|
||||
const usableDestinations = $derived(isRemote ? destList.filter(d => !isLocalRepo(d.repository)) : destList);
|
||||
// All destinations are selectable; a local repo on a non-co-located env fails
|
||||
// loud at run time (helper localRepoGuard), not hidden here.
|
||||
const usableDestinations = $derived(destList);
|
||||
// External stacks can't be backed up — exclude them from the unconfigured count
|
||||
// and from "Schedule all" so the number reflects only backup-able targets. The
|
||||
// list mixes stacks and standalone containers, and "Schedule all" configs BOTH,
|
||||
|
||||
Reference in new issue
Block a user