This commit is contained in:
jarek committed 2026-07-25 08:41:01 +02:00
1 parent 9f09d60a83
commit 64b6dcd744
177 files changed
+21182 -1042

No files matched your search

+129 -5
View File
@@ -39,9 +39,10 @@ import {
getStackSourceByComposePath
} from './db';
import { unregisterSchedule } from './scheduler';
import { sendEventNotification } from './notifications';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
import { cleanPem } from '$lib/utils/pem';
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
import { rewriteComposeVolumePaths, getHostDataDir, findRelativeBindSources } from './host-path';
import { getOrderValue } from './container-labels';
// =============================================================================
@@ -126,6 +127,12 @@ export interface DeployStackOptions {
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
filesToDelete?: FileToDelete[];
/** Set by deployGitStack: this deploy is a git sync, so deployStack must NOT emit
* the stack_deployed/stack_deploy_failed notification — the caller emits the more
* specific git_sync_success/git_sync_failed instead, avoiding a double notification
* (Stack events and Git sync are separate user-facing groups). stack_events is
* still recorded regardless. (#1295) */
isGitDeploy?: boolean;
}
// =============================================================================
@@ -1035,6 +1042,26 @@ async function executeLocalCompose(
}
}
// Cross-env safety check: if we're targeting a remote daemon (DOCKER_HOST
// set) and the compose has relative bind sources (./X, ../X), refuse the
// 'up' deploy with an actionable error. Otherwise docker compose expands
// ./X against Dockhand's CWD and asks the REMOTE daemon to bind that path,
// which silently creates an empty directory on the remote — the service
// starts with no data and the deploy reports success. (Other operations
// like down/stop/restart don't bind anything, so they pass through.)
if (dockerHost && operation === 'up') {
const relativeBinds = findRelativeBindSources(finalComposeContent);
if (relativeBinds.length > 0) {
const message = `This stack uses relative bind mounts (${relativeBinds.join(', ')}) which cannot be deployed to a remote Docker environment. The source paths are resolved on Dockhand's filesystem, not on the target host's. To fix: convert the bind mounts to named volumes, or use absolute paths that exist on the target host.`;
console.log(`${logPrefix} REFUSED: ${message}`);
return {
success: false,
output: '',
error: message
};
}
}
// 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.
@@ -2083,11 +2110,74 @@ export async function requireComposeFile(
};
}
/**
* Redeploy a stack from a COMPLETE stack directory (the whole tree captured in a
* backup snapshot, extracted to `stackDir`), using the ORIGINAL compose filename.
* Reproduces the stack 1:1 — `include:`, override files, and sibling configs
* referenced by relative paths resolve from the extracted dir, and the compose
* file keeps its real name (e.g. immich.yaml). For Hawser envs every file in the
* dir is shipped as stackFiles so the remote host gets the full tree too.
*
* The caller owns `stackDir`'s lifecycle (extract then remove). Throws if the
* chosen compose file is missing from the dir.
*/
export async function redeployStackFromDir(
stackName: string,
stackDir: string,
composeFileName: string,
envId?: number | null
): Promise<StackOperationResult> {
const composePath = join(stackDir, composeFileName);
if (!existsSync(composePath)) {
throw new Error(`compose file "${composeFileName}" not found in restored stack dir`);
}
const composeContent = readFileSync(composePath, 'utf-8');
if (!composeContent || composeContent.trim().length === 0) {
throw new Error('restored compose file is empty; cannot redeploy');
}
const envPath = join(stackDir, '.env');
const hasEnv = existsSync(envPath);
const envVars = hasEnv ? parseEnvFileContent(readFileSync(envPath, 'utf-8'), stackName) : undefined;
// For Hawser, ship the entire tree (compose + include:d files + sidecars + .env).
const stackFiles = await readDirFilesAsMap(stackDir);
return await executeComposeCommand(
'up',
{
stackName, envId,
workingDir: stackDir,
composePath,
envPath: hasEnv ? envPath : undefined,
composeFileName,
stackFiles
},
composeContent,
envVars
);
}
/**
* Start a stack using docker compose start (resumes stopped containers).
* Falls back to docker compose up if containers don't exist (stack was removed/down).
* Falls back to individual container start for stacks without compose files.
*/
/**
* Fire stack_started / stack_stopped after a successful start/stop. Best-effort;
* never changes the outcome. Only on success — a failed start/stop is not a
* "started/stopped" event. Individual container_started/stopped events still fire
* separately off the Docker event stream (different granularity). (#1295)
*/
async function notifyStackLifecycle(stackName: string, envId: number | null | undefined, event: 'stack_started' | 'stack_stopped', result: StackOperationResult): Promise<void> {
if (!result.success) return;
const started = event === 'stack_started';
try {
await sendEventNotification(event, {
title: started ? 'Stack started' : 'Stack stopped',
message: `Stack "${stackName}" ${started ? 'started' : 'stopped'}`,
type: 'success'
}, envId ?? undefined);
} catch { /* never changes the outcome */ }
}
export async function startStack(
stackName: string,
envId?: number | null
@@ -2096,7 +2186,9 @@ export async function startStack(
if (!result.success) {
// No compose file - fall back to container-based operations
return withContainerFallback(stackName, envId, 'start');
const fallback = await withContainerFallback(stackName, envId, 'start');
await notifyStackLifecycle(stackName, envId, 'stack_started', fallback);
return fallback;
}
// Check if this is a git stack - git stacks need useOverrideFile to write .env.dockhand
@@ -2111,13 +2203,15 @@ export async function startStack(
const containers = await getStackContainers(stackName, envId);
const operation = containers.length > 0 ? 'start' : 'up';
return executeComposeCommand(
const startResult = await executeComposeCommand(
operation,
opts,
result.content!,
result.nonSecretVars,
result.secretVars
);
await notifyStackLifecycle(stackName, envId, 'stack_started', startResult);
return startResult;
}
/**
@@ -2132,7 +2226,9 @@ export async function stopStack(
if (!result.success) {
// No compose file - fall back to container-based operations
return withContainerFallback(stackName, envId, 'stop');
const fallback = await withContainerFallback(stackName, envId, 'stop');
await notifyStackLifecycle(stackName, envId, 'stack_stopped', fallback);
return fallback;
}
const composeResult = await executeComposeCommand(
@@ -2146,6 +2242,7 @@ export async function stopStack(
// Stop any dynamically-spawned child containers not in the compose file
await cleanupOrphanStackContainers(stackName, envId, 'stop');
await notifyStackLifecycle(stackName, envId, 'stack_stopped', composeResult);
return composeResult;
}
@@ -2441,12 +2538,35 @@ export async function removeStack(
});
}
/**
* Fire the stack_deployed / stack_deploy_failed notification for a completed deploy.
* Called from the single deployStack() return point, so EVERY deploy path (local,
* Hawser, git webhook/manual) dispatches it — previously nothing did, so these
* notifications never fired (#1295). Best-effort: a notification failure never changes
* the deploy outcome (mirrors backups/index.ts notify()).
*/
async function notifyStackDeploy(name: string, envId: number | null | undefined, result: StackOperationResult, isGitDeploy: boolean): Promise<void> {
const eventType = result.success ? 'stack_deployed' : 'stack_deploy_failed';
// A git deploy suppresses the stack_* notification — deployGitStack emits the more
// specific git_sync_success/git_sync_failed instead (no double notification).
if (isGitDeploy) return;
try {
await sendEventNotification(eventType, {
title: result.success ? 'Stack deployed' : 'Stack deploy failed',
message: result.success
? `Stack "${name}" deployed successfully`
: `Stack "${name}" deploy failed: ${result.error || 'unknown error'}`,
type: result.success ? 'success' : 'error'
}, envId ?? undefined);
} catch { /* never changes the deploy outcome */ }
}
/**
* Deploy a stack (create or update)
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete, isGitDeploy } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2639,6 +2759,10 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
if (!result.deletion && localDeletionResult) {
result.deletion = localDeletionResult;
}
// Fire stack_deployed / stack_deploy_failed. This is the single point every deploy
// path funnels through, so all of them notify (#1295). A git deploy suppresses the
// stack_* notification (deployGitStack sends git_sync_*).
await notifyStackDeploy(name, envId, result, isGitDeploy ?? false);
return result;
});
}