start, stop or restart on Hawser updates its files on the remote host, not only on redeploy
This commit is contained in:
3 files changed
+207
-96
No files matched your search
@@ -1,29 +1,28 @@
|
||||
/**
|
||||
* Pure decision logic for staging a stack's files onto a `direct` remote host (no server
|
||||
* deps - safe to unit test). The IO (helper container + put-archive) lives in
|
||||
* Pure decision logic for copying a stack's files onto a `direct` remote host FOR BACKUP (no
|
||||
* server deps - safe to unit test). The IO (helper container + put-archive) lives in
|
||||
* stage-remote-stackfiles.ts; this module only decides IF and WHERE.
|
||||
*/
|
||||
import { normalizeBaseDir, stackDirIn } from './stack-paths';
|
||||
|
||||
export interface RemoteStagingPlan {
|
||||
stage: boolean;
|
||||
/** Absolute project dir on the remote host (<remoteBase>/<stack>), when stage is true. */
|
||||
projectDir?: string;
|
||||
/** Absolute stack dir on the remote host (<remoteBase>/<stack>), when stage is true. */
|
||||
hostDir?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a `direct` deploy should stage files onto the remote host, and where.
|
||||
* Decide whether a `direct` deploy should copy the stack folder onto the remote host, and where.
|
||||
* This is BACKUP-only: the backup helper bind-mounts the host dir to capture the compose/config.
|
||||
* It does NOT steer deploy - compose runs locally with the user's own bind paths (absolute or
|
||||
* matched 1:1), the same as a hawser agent whose STACKS_DIR is a backup-only declaration.
|
||||
*
|
||||
* Staging happens when ALL hold:
|
||||
* - operation is 'up' (down/stop/start/restart don't re-stage),
|
||||
* Copying happens when ALL hold:
|
||||
* - operation is 'up' (down/stop/start/restart don't re-copy),
|
||||
* - the env has a non-empty remote_stacks_dir configured,
|
||||
* - there are stack files to stage.
|
||||
* Setting remote_stacks_dir is the user's explicit "keep my stacks here on the host", so we
|
||||
* stage the WHOLE stack dir (compose + .env + sibling config) - not only when the compose has
|
||||
* relative binds. Relative binds still need it to resolve, but a bind-less stack needs its
|
||||
* compose on the host just as much: to be manageable and BACKUPABLE (the helper bind-mounts the
|
||||
* host dir; without the compose there, a backup can't capture it).
|
||||
* Any miss => stage:false => the deploy takes the exact current code path (no --project-directory).
|
||||
* - there are stack files to copy.
|
||||
* Any miss => stage:false => nothing is copied (stack-file backup is skipped for the env).
|
||||
*/
|
||||
export function planRemoteStaging(input: {
|
||||
operation: string;
|
||||
@@ -34,7 +33,48 @@ export function planRemoteStaging(input: {
|
||||
}): RemoteStagingPlan {
|
||||
if (input.operation !== 'up') return { stage: false, reason: `operation ${input.operation} does not stage` };
|
||||
if (!input.hasStackFiles) return { stage: false, reason: 'no stack files to stage' };
|
||||
const base = typeof input.remoteStacksDir === 'string' ? input.remoteStacksDir.trim().replace(/\/+$/, '') : '';
|
||||
const base = typeof input.remoteStacksDir === 'string' ? normalizeBaseDir(input.remoteStacksDir) : '';
|
||||
if (!base) return { stage: false, reason: 'no remote_stacks_dir configured on the env' };
|
||||
return { stage: true, projectDir: `${base}/${input.stackName}`, reason: 'direct env with remote_stacks_dir' };
|
||||
// SAME formula the backup resolver reads from (stackDirIn) - deploy COPIES here, backup READS here.
|
||||
return { stage: true, hostDir: stackDirIn(base, input.stackName), reason: 'direct env with remote_stacks_dir' };
|
||||
}
|
||||
|
||||
export interface BindRewriteResult {
|
||||
content: string;
|
||||
modified: boolean;
|
||||
/** Human-readable `./x -> <hostDir>/x` mappings, for logging. */
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a compose file's SAME-DIR relative bind sources (`./x`) to absolute paths under the
|
||||
* stack's host dir (`<hostDir>/x`), so a `direct` remote daemon binds the files the deploy
|
||||
* copied there instead of a path only Dockhand can see.
|
||||
*
|
||||
* Only `./x` is rewritten - that's exactly the subtree the staging copy delivers to `hostDir`.
|
||||
* A `../x` escapes the stack dir (never staged), so it is left untouched to fail loudly rather
|
||||
* than point at a bogus host path. Named volumes, absolute binds, and non-bind lines pass through.
|
||||
* The scheme is inferred from a compose line, so no YAML parser is needed (mirrors
|
||||
* rewriteComposeVolumePaths); each match is anchored to a `- <src>:<dest>` volume entry.
|
||||
*
|
||||
* Pure and dependency-free (safe to unit test). `hostDir` must be an absolute POSIX path.
|
||||
*/
|
||||
export function rewriteBindsToHostDir(composeContent: string, hostDir: string): BindRewriteResult {
|
||||
const base = hostDir.replace(/\/+$/, '');
|
||||
const changes: string[] = [];
|
||||
// - ./x:/dest where the WHOLE "src:dest" may be wrapped in matching quotes:
|
||||
// - ./data:/data | - "./data:/data" | - './data:/data'
|
||||
// `./` only - a `../` (or bare `.`) is intentionally NOT matched.
|
||||
const RE = /^(\s*-\s*)(['"]?)\.\/([^'":\s]+)(:[^'"]+)(\2)\s*$/;
|
||||
|
||||
const out = composeContent.split('\n').map((line) => {
|
||||
const m = line.match(RE);
|
||||
if (!m) return line;
|
||||
const [, prefix, quote, rel, dest] = m;
|
||||
const abs = `${base}/${rel}`;
|
||||
changes.push(` ./${rel} -> ${abs}`);
|
||||
return `${prefix}${quote}${abs}${dest}${quote}`;
|
||||
});
|
||||
|
||||
return { content: out.join('\n'), modified: changes.length > 0, changes };
|
||||
}
|
||||
+121
-53
@@ -37,9 +37,11 @@ import {
|
||||
deleteAutoUpdateSchedule,
|
||||
getAutoUpdateSetting,
|
||||
getStackSourceByComposePath,
|
||||
getSecretProviderById
|
||||
getSecretProviderById,
|
||||
setStackInjectedSecretKeys
|
||||
} from './db';
|
||||
import { getProvider } from './secretproviders';
|
||||
import { resolveComposeDockerHost, buildComposeBaseArgs } from './compose-docker-args';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
@@ -309,6 +311,26 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack-dir files for a LIFECYCLE op (start/stop/restart/down) on Hawser.
|
||||
*
|
||||
* Deploy ships the stack dir as stackFiles so the agent materializes the tree and runs
|
||||
* `-f <dir>/compose.yaml`; the lifecycle ops didn't, so the agent fell back to `-f -`
|
||||
* (stdin) and any include:/sibling file the compose references was ABSENT on the agent,
|
||||
* breaking down/stop (#1240). Give them the same map. Ignored by local/socket/direct
|
||||
* (executeLocalCompose has no stackFiles param); only the Hawser branch consumes it.
|
||||
* Best-effort: a missing/unreadable dir returns undefined -> exact prior behavior.
|
||||
*/
|
||||
async function lifecycleStackFiles(stackDir?: string): Promise<Record<string, string> | undefined> {
|
||||
if (!stackDir || !existsSync(stackDir)) return undefined;
|
||||
try {
|
||||
const files = await readDirFilesAsMap(stackDir);
|
||||
return Object.keys(files).length > 0 ? files : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEBUG UTILITIES
|
||||
// =============================================================================
|
||||
@@ -1001,7 +1023,10 @@ async function executeLocalCompose(
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string,
|
||||
remoteProjectDir?: string
|
||||
// direct-remote only: when the stack folder was staged to <remoteStackHostDir> on the target
|
||||
// host, rewrite the compose's same-dir relative binds (`./x`) to <remoteStackHostDir>/x so the
|
||||
// remote daemon binds the staged files. undefined = no staging, compose unchanged.
|
||||
remoteStackHostDir?: string
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
|
||||
@@ -1060,6 +1085,19 @@ async function executeLocalCompose(
|
||||
}
|
||||
}
|
||||
|
||||
// direct-remote: the stack folder was copied to <remoteStackHostDir> on the target host, so
|
||||
// rewrite same-dir relative binds (`./x`) to <remoteStackHostDir>/x. This resolves them on the
|
||||
// remote daemon without --project-directory (which would break `include:`). include stays local.
|
||||
if (remoteStackHostDir) {
|
||||
const { rewriteBindsToHostDir } = await import('./remote-staging-plan');
|
||||
const rw = rewriteBindsToHostDir(finalComposeContent, remoteStackHostDir);
|
||||
if (rw.modified) {
|
||||
finalComposeContent = rw.content;
|
||||
console.log(`${logPrefix} direct env: rewrote ${rw.changes.length} relative bind(s) to the staged host dir:`);
|
||||
for (const change of rw.changes) console.log(`${logPrefix}${change}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build spawn environment with ONLY essential system variables.
|
||||
// CRITICAL: Do NOT spread process.env! Docker Compose shell env has higher
|
||||
// priority than --env-file, so Dockhand's vars would override user's .env values.
|
||||
@@ -1068,12 +1106,12 @@ async function executeLocalCompose(
|
||||
HOME: process.env.HOME || '/root',
|
||||
};
|
||||
|
||||
// Docker connection config
|
||||
if (dockerHost) {
|
||||
spawnEnv.DOCKER_HOST = dockerHost;
|
||||
} else if (process.env.DOCKER_HOST) {
|
||||
spawnEnv.DOCKER_HOST = process.env.DOCKER_HOST;
|
||||
}
|
||||
// Docker connection config. Pass the daemon via the `docker -H` CLI FLAG (built
|
||||
// into `args` below), NOT via DOCKER_HOST in the shell env: shell env leaks into
|
||||
// services that pass through or interpolate DOCKER_HOST (e.g. a socket-proxy
|
||||
// sidecar), overriding the value the stack set for itself (#1393). `-H` connects
|
||||
// compose to the right daemon without polluting the compose interpolation env.
|
||||
const composeDockerHost = resolveComposeDockerHost(dockerHost, process.env.DOCKER_HOST);
|
||||
|
||||
// Honor explicit DOCKER_API_VERSION override from environment (user-controlled).
|
||||
// Otherwise let compose negotiate natively — 5.0.2 handles old daemons correctly.
|
||||
@@ -1081,6 +1119,16 @@ async function executeLocalCompose(
|
||||
spawnEnv.DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
|
||||
}
|
||||
|
||||
// DOCKER_CONFIG points the Docker CLI at its config dir (where `docker login` writes
|
||||
// credentials). loginToRegistries() runs with the full process env and writes to
|
||||
// $DOCKER_CONFIG/config.json; without passing it through here, compose would read from
|
||||
// $HOME/.docker instead and every private-image pull falls back to anonymous -> 401
|
||||
// (#1376). It's a CLI-config var, not a Compose interpolation var, so it can't collide
|
||||
// with user .env values - safe to allow-list next to the other DOCKER_* vars.
|
||||
if (process.env.DOCKER_CONFIG) {
|
||||
spawnEnv.DOCKER_CONFIG = process.env.DOCKER_CONFIG;
|
||||
}
|
||||
|
||||
// Check if .env file exists on disk (for legacy support decision)
|
||||
const defaultEnvPath = join(composeFileDir, '.env');
|
||||
const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath));
|
||||
@@ -1153,16 +1201,9 @@ async function executeLocalCompose(
|
||||
// Build command based on operation
|
||||
// If we have modified compose content (host path translation), use stdin instead of file
|
||||
const useStdin = finalComposeContent !== composeContent;
|
||||
const args = ['docker', 'compose', '-p', stackName];
|
||||
|
||||
// For a `direct` remote daemon we can't share Dockhand's filesystem, so relative binds
|
||||
// (./config, ./data) are staged onto the remote host under `remoteProjectDir` and the
|
||||
// compose is pointed there with --project-directory: compose resolves `./x` against that
|
||||
// remote path (which need not exist locally) instead of the client cwd, so the daemon
|
||||
// binds the staged files. undefined = current behavior (no staging, no flag).
|
||||
if (remoteProjectDir) {
|
||||
args.push('--project-directory', remoteProjectDir);
|
||||
}
|
||||
// `-H` is a GLOBAL docker flag, so it goes before `compose`. This connects to the
|
||||
// daemon without putting DOCKER_HOST in the shell env (#1393 - see above).
|
||||
const args = buildComposeBaseArgs(stackName, composeDockerHost);
|
||||
|
||||
// Temp file for path-translated override content (cleaned up in finally block)
|
||||
let tempOverridePath: string | undefined;
|
||||
@@ -1191,12 +1232,6 @@ async function executeLocalCompose(
|
||||
args.push('-f', overrideFile);
|
||||
console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`);
|
||||
}
|
||||
} else if (remoteProjectDir) {
|
||||
// Internal stack + remote staging: --project-directory repoints compose's base to the
|
||||
// remote path, which breaks the usual cwd auto-discovery of compose.yaml (that file is
|
||||
// on Dockhand's host, not under the remote project dir). Point -f at the local file
|
||||
// explicitly so compose reads it locally while resolving ./binds against the remote dir.
|
||||
args.push('-f', composeFile);
|
||||
}
|
||||
// else: internal stack without path translation - no -f needed.
|
||||
// Docker Compose auto-discovers compose.yaml + compose.override.yaml from cwd.
|
||||
@@ -1711,31 +1746,31 @@ async function executeComposeCommand(
|
||||
skipVerify: env.tlsSkipVerify ?? false
|
||||
} : undefined;
|
||||
|
||||
// A direct daemon shares no filesystem with Dockhand, so relative binds
|
||||
// (./config, ./data) would resolve to a path the remote daemon can't see and
|
||||
// Docker would auto-create empty dirs. When the env has a `remote_stacks_dir`
|
||||
// set AND the compose has relative binds, stage the stack files onto the remote
|
||||
// host under <remoteDir>/<stack> and point compose there with --project-directory.
|
||||
// The plan() decides IF/WHERE (pure, unit-tested); anything it declines takes the
|
||||
// exact current path (remoteProjectDir stays undefined = no flag, no staging).
|
||||
let remoteProjectDir: string | undefined;
|
||||
// A `direct` env with a `remote_stacks_dir` set gets its WHOLE stack folder
|
||||
// (compose + includes + .env + sibling config) copied onto the target host under
|
||||
// <remoteDir>/<stack>, so the backup helper can bind-mount it there. This is
|
||||
// When the env has a `remote_stacks_dir`, Dockhand copies the whole stack folder to
|
||||
// <remoteDir>/<stack> on the target host so the backup helper can read it AND so the
|
||||
// compose's same-dir relative binds (`./data`) can be rewritten to that absolute host
|
||||
// path (done inside executeLocalCompose). This resolves relative binds on the remote
|
||||
// daemon WITHOUT --project-directory (which would break `include:`). No remote_stacks_dir
|
||||
// -> nothing staged, compose unchanged: relative binds resolve against the local cwd
|
||||
// (1.0.37 behavior), absolute/named binds work.
|
||||
let remoteStackHostDir: string | undefined;
|
||||
{
|
||||
const { getEnvSetting } = await import('./db');
|
||||
const { planRemoteStaging } = await import('./remote-staging-plan');
|
||||
const remoteStacksDir = await getEnvSetting('remote_stacks_dir', envId ?? undefined);
|
||||
// Stage the WHOLE local stack dir (compose + relative-bind files like nginx.conf,
|
||||
// ./data). The local stack dir is config-only - runtime data written by the remote
|
||||
// container never comes back here - so this can't clobber live data. The tar is
|
||||
// STREAMED from disk (O(1) RAM), so a large ./data doesn't buffer in memory.
|
||||
// The tar is STREAMED from disk (O(1) RAM), so a large stack dir doesn't buffer.
|
||||
const hasLocalDir = !!(operation === 'up' && workingDir && existsSync(workingDir));
|
||||
const plan = planRemoteStaging({
|
||||
operation, remoteStacksDir, stackName, composeContent, hasStackFiles: hasLocalDir,
|
||||
});
|
||||
if (plan.stage && plan.projectDir && workingDir) {
|
||||
if (plan.stage && plan.hostDir && workingDir) {
|
||||
const { stageStackDirOnRemote } = await import('./stage-remote-stackfiles');
|
||||
const { staged } = await stageStackDirOnRemote(envId!, plan.projectDir, workingDir);
|
||||
console.log(`[Stack:${stackName}] direct env: staged ${staged} file(s) to ${plan.projectDir} on the remote host (${plan.reason})`);
|
||||
remoteProjectDir = plan.projectDir;
|
||||
const { staged } = await stageStackDirOnRemote(envId!, plan.hostDir, workingDir);
|
||||
console.log(`[Stack:${stackName}] direct env: staged ${staged} file(s) to ${plan.hostDir} on the remote host (${plan.reason})`);
|
||||
remoteStackHostDir = plan.hostDir;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1758,7 +1793,7 @@ async function executeComposeCommand(
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy,
|
||||
remoteProjectDir
|
||||
remoteStackHostDir
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2247,7 +2282,7 @@ export async function startStack(
|
||||
// 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 };
|
||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) };
|
||||
|
||||
// Check if containers exist for this stack. If they do, use 'start' to resume
|
||||
// them (preserves container IDs, avoids Traefik race conditions from recreation).
|
||||
@@ -2295,7 +2330,7 @@ export async function stopStack(
|
||||
|
||||
const composeResult = await executeComposeCommand(
|
||||
'stop',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack },
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
@@ -2336,7 +2371,7 @@ export async function restartStack(
|
||||
// sourceType is plumbed through from requireComposeFile 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 };
|
||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) };
|
||||
|
||||
let composeResult: StackOperationResult;
|
||||
|
||||
@@ -2377,7 +2412,7 @@ export async function downStack(
|
||||
|
||||
const composeResult = await executeComposeCommand(
|
||||
'down',
|
||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack },
|
||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
@@ -2521,11 +2556,12 @@ export async function removeStack(
|
||||
if (deleteFiles && envId != null) {
|
||||
try {
|
||||
const { getEnvironment, getEnvSetting } = await import('./db');
|
||||
const { normalizeBaseDir, stackDirIn } = await import('./stack-paths');
|
||||
const env = await getEnvironment(envId);
|
||||
if (env?.connectionType === 'direct') {
|
||||
const remoteStacksDir = await getEnvSetting('remote_stacks_dir', envId);
|
||||
const base = typeof remoteStacksDir === 'string' ? remoteStacksDir.trim().replace(/\/+$/, '') : '';
|
||||
if (base) console.log(`[Stack:${stackName}] leaving staged files at ${base}/${stackName} on the remote host (not deleting - may hold user data)`);
|
||||
const base = typeof remoteStacksDir === 'string' && remoteStacksDir.trim() ? normalizeBaseDir(remoteStacksDir) : '';
|
||||
if (base) console.log(`[Stack:${stackName}] leaving staged files at ${stackDirIn(base, stackName)} on the remote host (not deleting - may hold user data)`);
|
||||
}
|
||||
} catch { /* log-only, never blocks removal */ }
|
||||
}
|
||||
@@ -2860,6 +2896,9 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
} else {
|
||||
// Default: compose file should already exist (written by saveStackComposeFile)
|
||||
workingDir = await getStackDir(name, envId);
|
||||
// Point at the default .env in the stack dir so its content (e.g. a
|
||||
// bulk secret selector) reaches resolveProviderEnvVars below.
|
||||
actualEnvPath = join(workingDir, '.env');
|
||||
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
|
||||
}
|
||||
|
||||
@@ -2902,7 +2941,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
initialSecretVars,
|
||||
logPrefix,
|
||||
source?.secretProviderId,
|
||||
envFileContent
|
||||
envFileContent,
|
||||
{ stackName: name, envId }
|
||||
);
|
||||
console.log(`${logPrefix} DB non-secret override vars:`, Object.keys(dbNonSecretVars).length);
|
||||
console.log(`${logPrefix} DB secret vars:`, Object.keys(secretVars).length);
|
||||
@@ -3232,6 +3272,10 @@ export async function saveStackEnvVars(
|
||||
interface EnrichedEnvVars {
|
||||
dbNonSecretVars: Record<string, string>;
|
||||
secretVars: Record<string, string>;
|
||||
// Names (no values) of secret keys pulled/resolved from the bound provider this
|
||||
// deploy - bulk keys and inline refs promoted to secrets. Container inspect masks
|
||||
// these; they are not in stack_env_vars, so getSecretKeysToMask can't see them.
|
||||
injectedProviderKeys: string[];
|
||||
}
|
||||
|
||||
// Variable names that trigger a bulk pull. OP_ENVIRONMENT_ID is retained for
|
||||
@@ -3243,10 +3287,30 @@ async function resolveProviderEnvVars(
|
||||
secretVars: Record<string, string>,
|
||||
logPrefix: string,
|
||||
secretProviderId?: number | null,
|
||||
stackEnvFileContent?: string
|
||||
stackEnvFileContent?: string,
|
||||
persistTo?: { stackName: string; envId: number | null | undefined }
|
||||
): Promise<EnrichedEnvVars> {
|
||||
const envFileVars = stackEnvFileContent ? parseEnvFileContent(stackEnvFileContent) : {};
|
||||
|
||||
// Keys already present as DB secrets on entry; anything in secretVars beyond
|
||||
// these at the end is provider-injected (bulk pull or promoted inline ref).
|
||||
const dbSecretKeysOnEntry = new Set(Object.keys(secretVars));
|
||||
const injectedProviderKeys = (): string[] =>
|
||||
Object.keys(secretVars).filter((k) => !dbSecretKeysOnEntry.has(k));
|
||||
|
||||
// Persist the provider-injected key NAMES (no values) so container inspect can
|
||||
// mask them. Done HERE, the single resolution choke point, so no caller can
|
||||
// forget it (the primary deployStack path used to). Best-effort: a persist
|
||||
// failure must never block a deploy.
|
||||
const persistInjectedKeys = async () => {
|
||||
if (!persistTo) return;
|
||||
try {
|
||||
await setStackInjectedSecretKeys(persistTo.stackName, persistTo.envId, injectedProviderKeys());
|
||||
} catch (err) {
|
||||
console.warn(`${logPrefix} Failed to persist injected secret key names:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const providerRow = secretProviderId ? await getSecretProviderById(secretProviderId) : undefined;
|
||||
const provider = providerRow ? getProvider(providerRow.type) : undefined;
|
||||
|
||||
@@ -3310,12 +3374,14 @@ async function resolveProviderEnvVars(
|
||||
for (const ref of envFileRefs.values()) refs.add(ref);
|
||||
|
||||
if (refs.size === 0) {
|
||||
return { dbNonSecretVars, secretVars };
|
||||
await persistInjectedKeys();
|
||||
return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() };
|
||||
}
|
||||
|
||||
if (!providerRow || !provider) {
|
||||
console.warn(`${logPrefix} Found ${refs.size} reference(s) but no usable secret provider is bound to this stack; leaving them as literals`);
|
||||
return { dbNonSecretVars, secretVars };
|
||||
await persistInjectedKeys();
|
||||
return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() };
|
||||
}
|
||||
|
||||
let refMap: Map<string, string>;
|
||||
@@ -3358,7 +3424,8 @@ async function resolveProviderEnvVars(
|
||||
|
||||
console.log(`${logPrefix} ${provider.label} resolved ${refMap.size}/${refs.size} reference(s) (promoted from DB: ${promotedFromDb}, from .env: ${promotedFromEnvFile})`);
|
||||
|
||||
return { dbNonSecretVars, secretVars };
|
||||
await persistInjectedKeys();
|
||||
return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3390,7 +3457,8 @@ async function applyProviderSecretsToComposeResult(
|
||||
{ ...result.secretVars },
|
||||
logPrefix,
|
||||
source?.secretProviderId,
|
||||
envFileContent
|
||||
envFileContent,
|
||||
{ stackName, envId: envId ?? undefined }
|
||||
);
|
||||
result.nonSecretVars = dbNonSecretVars;
|
||||
result.secretVars = secretVars;
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
} from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import IconPicker from '$lib/components/icon-picker.svelte';
|
||||
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
|
||||
import { isCustomIcon } from '$lib/utils/icons';
|
||||
@@ -297,9 +296,9 @@
|
||||
let formDiskWarningThreshold = $state(80);
|
||||
let formDiskWarningThresholdGb = $state(50);
|
||||
let formConnectionType = $state<ConnectionType>('socket');
|
||||
// Envs that keep stack files on a REMOTE host, so backup needs a declared stack path:
|
||||
// direct (Dockhand stages there, drives deploy + backup) and hawser (the agent keeps files
|
||||
// at its STACKS_DIR; the path is backup-only, does not steer deploy).
|
||||
// Envs that keep stack files on a REMOTE host, so backup needs a declared stack path.
|
||||
// For hawser it is backup-only (the agent owns its STACKS_DIR). For direct it also drives
|
||||
// deploy: Dockhand copies the folder there and rewrites relative binds to that host path.
|
||||
const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge';
|
||||
const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge';
|
||||
let formHawserToken = $state('');
|
||||
@@ -1782,13 +1781,11 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label for="edit-env-connection-type">Connection type</Label>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<button type="button" class="text-muted-foreground hover:text-foreground">
|
||||
<HelpCircle class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 text-sm z-[200]" side="right">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger type="button" class="text-muted-foreground hover:text-foreground">
|
||||
<HelpCircle class="w-3.5 h-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="w-80 text-sm z-[200]" side="right">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<Unplug class="w-4 h-4 mt-0.5 text-cyan-500 shrink-0" />
|
||||
@@ -1823,8 +1820,8 @@
|
||||
Learn more about Hawser
|
||||
</a>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Select.Root type="single" value={formConnectionType} onValueChange={(v) => {
|
||||
formConnectionType = v as ConnectionType;
|
||||
@@ -1947,12 +1944,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stack path for backup (direct = also drives deploy; hawser = backup-only) -->
|
||||
<!-- Stack path: hawser = backup-only; direct = backup + relative-bind rewrite on deploy -->
|
||||
{#if usesStackPath(formConnectionType)}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label for="edit-env-remote-stacks-dir">
|
||||
{isHawserConn(formConnectionType) ? 'Remote stack path (for backup)' : 'Remote stacks directory'}
|
||||
Remote stack path (for backup)
|
||||
<span class="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
@@ -1976,23 +1973,28 @@
|
||||
(default <code class="bg-muted px-1 rounded">/data/stacks</code>). Leave empty for
|
||||
the default; set it only if the agent runs with a custom one.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
This must be a <span class="font-medium text-foreground">real path on the agent's host</span>
|
||||
where the stack files actually live.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium">How this path is used</p>
|
||||
<p class="font-medium">Where this stack's files live on the host</p>
|
||||
<p class="text-muted-foreground">
|
||||
A direct daemon shares no filesystem with Dockhand. When set, Dockhand
|
||||
materialises each stack's whole folder - compose, <code class="bg-muted px-1 rounded">.env</code>,
|
||||
and relative-bind files (<code class="bg-muted px-1 rounded">./config</code>,
|
||||
<code class="bg-muted px-1 rounded">./data</code>) - into
|
||||
A direct daemon shares no filesystem with Dockhand. When set, Dockhand copies each
|
||||
stack's folder to
|
||||
<code class="bg-muted px-1 rounded"><this path>/<stack></code>
|
||||
<span class="font-medium text-foreground">on the remote host</span>, and runs compose there
|
||||
via <code class="bg-muted px-1 rounded">--project-directory</code>.
|
||||
<span class="font-medium text-foreground">on the remote host</span> so the backup
|
||||
helper can read the compose and config, and rewrites relative binds
|
||||
(<code class="bg-muted px-1 rounded">./data</code>) to that host path so they resolve
|
||||
on the remote daemon.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
This applies to normal stacks and git sync alike, so relative binds resolve
|
||||
and every stack is backupable. Leave empty to use only named volumes or
|
||||
absolute paths.
|
||||
Leave empty to skip this: the stack won't be backupable and a relative bind resolves
|
||||
to a path only Dockhand can see. Use
|
||||
<span class="font-medium text-foreground">absolute paths</span> or
|
||||
<span class="font-medium text-foreground">named volumes</span> instead.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2009,8 +2011,9 @@
|
||||
Absolute path on the agent's host where it keeps stack folders. Used only to back up
|
||||
each stack's compose and config. Leave empty for the default <code class="bg-muted px-1 rounded">/data/stacks</code>.
|
||||
{:else}
|
||||
Absolute path on the remote host where Dockhand stages each stack's files before deploying.
|
||||
Set it for stacks with relative binds, or to keep stacks backupable on a remote direct daemon.
|
||||
Absolute path on the remote host where Dockhand keeps this stack's files, so its compose
|
||||
and config are backupable and relative binds resolve on the remote daemon. Leave empty to
|
||||
use only absolute paths or named volumes.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user