restore reproduces the stack as an internal stack with its secrets
This commit is contained in:
7 files changed
+181
-34
No files matched your search
@@ -90,8 +90,10 @@ export interface RestorePorts {
|
||||
/** Recreate a container AS-IS from the snapshot's stored inspect config; throws
|
||||
* with a clear message if that metadata is missing/insufficient. */
|
||||
recreateContainerFromMetadata(name: string, envId: number | null | undefined, snapshotMetaContainer: unknown): Promise<void>;
|
||||
/** Redeploy a stack from the snapshot's stored compose files; throws if absent. */
|
||||
redeployStack(name: string, envId: number | null | undefined, destinationId: number, snapshotId: string): Promise<void>;
|
||||
/** Redeploy a stack from the snapshot's stored compose files; throws if absent.
|
||||
* restoreSecrets (opt-in, default on): write the stack's secrets carried in the
|
||||
* snapshot into the target env's DB before redeploy so the stack comes up complete. */
|
||||
redeployStack(name: string, envId: number | null | undefined, destinationId: number, snapshotId: string, restoreSecrets?: boolean): Promise<void>;
|
||||
/** Read the snapshot's stored restore metadata (metadata.json), or null. */
|
||||
readSnapshotMetadata(destinationId: number, snapshotId: string): Promise<any | null>;
|
||||
/** Write the snapshot's captured stack files (/metadata/stacks/<name>/stackfiles/)
|
||||
@@ -147,6 +149,10 @@ export interface RestoreJob {
|
||||
* Presence of ANY entry (or a recreate/redeploy postRestore) selects the clone
|
||||
* path. */
|
||||
volumeDestinations?: Array<{ volume: string; kind: 'volume' | 'path'; target: string }>;
|
||||
/** Whether to restore the stack's secrets carried in the snapshot (default true).
|
||||
* false = bring the stack up without secrets (the operator re-enters them). Only
|
||||
* meaningful for a stack redeploy. */
|
||||
restoreSecrets?: boolean;
|
||||
}
|
||||
|
||||
export class RestoreService {
|
||||
@@ -617,7 +623,7 @@ export class RestoreService {
|
||||
return { action, ok: false, error: 'no stack files stored in this snapshot; cannot redeploy' };
|
||||
}
|
||||
op.progress('redeploying', `Redeploying stack ${name}...`);
|
||||
await this.ports.redeployStack(name, envId, job.destinationId, job.snapshotId);
|
||||
await this.ports.redeployStack(name, envId, job.destinationId, job.snapshotId, job.restoreSecrets ?? true);
|
||||
return { action, ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -81,7 +81,7 @@ import {
|
||||
} from './db/drizzle.js';
|
||||
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt, decryptStrict } from './encryption.js';
|
||||
import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
|
||||
|
||||
@@ -4999,6 +4999,40 @@ export async function getSecretEnvVarsAsRecord(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a stack's SECRET env vars as their RAW at-rest ciphertext (enc:v1:...), keyed
|
||||
* by name. Unlike the *AsRecord helpers this does NOT decrypt: it returns the exact
|
||||
* bytes stored in the DB. Used by backup to carry secrets in a snapshot still
|
||||
* encrypted under this instance's key — never plaintext. A value that is somehow
|
||||
* stored plaintext (legacy) is encrypted here so a snapshot never holds a bare secret.
|
||||
*/
|
||||
export async function getStackSecretCiphertexts(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Array<{ key: string; value: string }>> {
|
||||
let rows;
|
||||
if (environmentId === undefined) {
|
||||
rows = await db.select().from(stackEnvironmentVariables)
|
||||
.where(eq(stackEnvironmentVariables.stackName, stackName));
|
||||
} else if (environmentId === null) {
|
||||
rows = await db.select().from(stackEnvironmentVariables)
|
||||
.where(and(
|
||||
eq(stackEnvironmentVariables.stackName, stackName),
|
||||
isNull(stackEnvironmentVariables.environmentId)
|
||||
));
|
||||
} else {
|
||||
rows = await db.select().from(stackEnvironmentVariables)
|
||||
.where(and(
|
||||
eq(stackEnvironmentVariables.stackName, stackName),
|
||||
eq(stackEnvironmentVariables.environmentId, environmentId)
|
||||
));
|
||||
}
|
||||
return rows
|
||||
.filter((r: typeof rows[number]) => r.isSecret && r.value)
|
||||
.map((r: typeof rows[number]) => ({ key: r.key, value: isEncrypted(r.value) ? r.value : (encrypt(r.value) ?? '') }))
|
||||
.filter((r: { key: string; value: string }) => r.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only NON-SECRET environment variables as a key-value record.
|
||||
* Used for .env file operations where secrets should be excluded.
|
||||
|
||||
@@ -2126,6 +2126,11 @@ export async function redeployStackFromDir(
|
||||
const envPath = join(stackDir, '.env');
|
||||
const hasEnv = existsSync(envPath);
|
||||
const envVars = hasEnv ? parseEnvFileContent(readFileSync(envPath, 'utf-8'), stackName) : undefined;
|
||||
// Secret env vars are stored encrypted in the DB and deliberately never written
|
||||
// to the snapshot's .env, so the extracted dir has no copy of them. Load them
|
||||
// from the DB and inject them like every other compose path does (#1329) —
|
||||
// otherwise a restored stack comes up with its secrets interpolating to "".
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
// For Hawser, ship the entire tree (compose + include:d files + sidecars + .env).
|
||||
const stackFiles = await readDirFilesAsMap(stackDir);
|
||||
return await executeComposeCommand(
|
||||
@@ -2139,7 +2144,8 @@ export async function redeployStackFromDir(
|
||||
stackFiles
|
||||
},
|
||||
composeContent,
|
||||
envVars
|
||||
envVars,
|
||||
secretVars
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2180,8 +2186,8 @@ export async function startStack(
|
||||
}
|
||||
|
||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
||||
// sourceType is plumbed through from requireComposeFile (which already looked
|
||||
// it up via getStackComposeFile/getStackSource) to avoid a redundant DB lookup.
|
||||
// sourceType is plumbed through from requireComposeFile (which already looked it up
|
||||
// via getStackComposeFile/getStackSource) to avoid a redundant DB lookup.
|
||||
const isGitStack = result.sourceType === 'git';
|
||||
|
||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
|
||||
@@ -2220,7 +2226,9 @@ export async function stopStack(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
||||
// Git stacks need useOverrideFile so `.env.dockhand` (the panel vars) is passed via
|
||||
// --env-file; otherwise `docker compose stop` re-interpolates ${VAR:?} in the compose
|
||||
// file with the panel vars missing and errors (#1313). Matches startStack/deployStack.
|
||||
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
|
||||
const isGitStack = result.sourceType === 'git';
|
||||
|
||||
@@ -2301,7 +2309,7 @@ export async function downStack(
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
||||
// useOverrideFile for git stacks — same reason as stopStack (#1313).
|
||||
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
|
||||
const isGitStack = result.sourceType === 'git';
|
||||
|
||||
@@ -2333,6 +2341,11 @@ export async function removeStack(
|
||||
// Get compose file (may not exist for external stacks)
|
||||
const composeResult = await getStackComposeFile(stackName, envId);
|
||||
|
||||
// useOverrideFile for git stacks — same reason as stopStack (#1313).
|
||||
// sourceType is plumbed through from getStackComposeFile (which already looked it
|
||||
// up via getStackSource) to avoid a redundant DB lookup.
|
||||
const isGitStack = composeResult.sourceType === 'git';
|
||||
|
||||
// Get stack containers BEFORE removing them (for cleanup later)
|
||||
const stackContainers = await getStackContainers(stackName, envId);
|
||||
|
||||
@@ -2367,6 +2380,7 @@ export async function removeStack(
|
||||
workingDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
envPath: composeResult.envPath ?? undefined,
|
||||
useOverrideFile: isGitStack,
|
||||
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
|
||||
removeFiles: true,
|
||||
filesToDelete: removalFiles
|
||||
|
||||
Reference in new issue
Block a user