/** * Stack Management Module * * Provides compose-first stack operations for internal, git, and external stacks. * All lifecycle operations use docker compose commands. */ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs'; import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path'; import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { applyFileDeletions, hashDirFiles, skipReasonMessage, normalizeSkipReason, type FileToDelete, type DeletionApplyResult, type DeletionSkipReason } from './git-deletions'; import { isAllowedStackFilename } from './stack-filename'; import { getEnvironment, getSecretEnvVarsAsRecord, getNonSecretEnvVarsAsRecord, getStackEnvVars, setStackEnvVars, getStackSource, upsertStackSource, deleteStackSource, getGitStackByName, deleteGitStack, getStackSources, deleteStackEnvVars, removePendingContainerUpdate, getPendingContainerUpdates, deleteAutoUpdateSchedule, getAutoUpdateSetting, getStackSourceByComposePath, 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'; import { isDeletableStackDir } from './stack-delete-guard'; import { cleanPem } from '$lib/utils/pem'; import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; import { getOrderValue } from './container-labels'; import { pendingRowsToClear } from './pending-updates-core'; // ============================================================================= // TYPES // ============================================================================= /** * TLS configuration for remote Docker connections */ interface TlsConfig { ca?: string; cert?: string; key?: string; skipVerify?: boolean; } /** * Stack source types */ export type StackSourceType = 'internal' | 'git' | 'external'; /** * Stack operation result */ export interface StackOperationResult { success: boolean; output?: string; error?: string; /** The docker compose command that was executed (for debugging/testing) */ command?: string; /** Result of applying git deletion sync (files removed / kept, with reasons) */ deletion?: DeletionApplyResult; } /** * Container detail within a stack */ export interface ContainerDetail { id: string; name: string; service: string; state: string; status: string; health?: string; image: string; ports: Array<{ publicPort: number; privatePort: number; type: string; display: string }>; networks: Array<{ name: string; ipAddress: string }>; volumeCount: number; restartCount: number; exitCode?: number; created: number; labels: Record; } /** * Compose stack information */ export interface ComposeStackInfo { name: string; containers: string[]; containerDetails: ContainerDetail[]; status: 'running' | 'stopped' | 'partial' | 'created'; sourceType?: StackSourceType; hasComposeFile?: boolean; } /** * Stack deployment options */ export interface DeployStackOptions { name: string; compose: string; envId?: number | null; sourceDir?: string; // Directory to copy all files from (for git stacks) forceRecreate?: boolean; build?: boolean; // Build images before starting (--build) noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build) pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never' composePath?: string; // Custom compose file path (for adopted/imported stacks) envPath?: string; // Custom env file path (for adopted/imported stacks) composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks 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; } // ============================================================================= // ERRORS // ============================================================================= /** * Error when compose file is missing for a managed stack */ export class ComposeFileNotFoundError extends Error { public readonly stackName: string; constructor(stackName: string) { super( `Compose file not found for stack "${stackName}". ` + `The stack may have been deleted or was created outside of Dockhand.` ); this.name = 'ComposeFileNotFoundError'; this.stackName = stackName; } } // ============================================================================= // INTERNAL STATE // ============================================================================= // Cache stacks directory let _stacksDir: string | null = null; // Per-stack locking mechanism to prevent race conditions during concurrent operations const stackLocks = new Map>(); // Track active TLS temp directories for cleanup on unexpected process exit const activeTlsDirs = new Set(); // Register cleanup handlers once at module load if (typeof process !== 'undefined') { const cleanupTlsDirs = () => { for (const dir of activeTlsDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } activeTlsDirs.clear(); }; process.on('exit', cleanupTlsDirs); process.on('SIGINT', () => { cleanupTlsDirs(); process.exit(130); }); process.on('SIGTERM', () => { cleanupTlsDirs(); process.exit(143); }); } /** * Execute a function with exclusive lock on a stack. * Prevents race conditions when multiple operations target the same stack. */ async function withStackLock(stackName: string, fn: () => Promise): Promise { const lockKey = stackName; // Wait for any existing lock to release while (stackLocks.has(lockKey)) { await stackLocks.get(lockKey); } // Create new lock let releaseLock: () => void; const lockPromise = new Promise((resolve) => { releaseLock = resolve; }); stackLocks.set(lockKey, lockPromise); try { return await fn(); } finally { stackLocks.delete(lockKey); releaseLock!(); } } // Timeout configuration for compose operations (configurable via COMPOSE_TIMEOUT env var in seconds) const COMPOSE_TIMEOUT_MS = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000; // Default 15 min const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL /** * Check if content is binary (not valid UTF-8 text). */ const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); function isBinaryContent(bytes: Uint8Array): boolean { try { utf8Decoder.decode(bytes); return false; } catch { return true; } } /** * Collect stdout/stderr from a child process and wait for it to exit. */ function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); proc.on('error', reject); proc.on('close', (code) => { resolve({ exitCode: code ?? 1, stdout: Buffer.concat(stdoutChunks).toString(), stderr: Buffer.concat(stderrChunks).toString() }); }); }); } /** * Read all files from a directory as a map of relative path -> content. * Used to send files to Hawser for remote deployments. * Binary files are base64-encoded with a "base64:" prefix to preserve all bytes. */ // Max file size: 10 MB per file, 256 MB total payload const MAX_FILE_SIZE = 10 * 1024 * 1024; const MAX_TOTAL_SIZE = 256 * 1024 * 1024; async function readDirFilesAsMap(dirPath: string): Promise> { const files: Record = {}; let totalSize = 0; const skipped: string[] = []; async function scanDir(currentPath: string, relativePath: string = ''): Promise { const entries = readdirSync(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(currentPath, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { // Skip .git directory if (entry.name === '.git') continue; await scanDir(fullPath, relPath); } else if (entry.isFile()) { const fileSize = statSync(fullPath).size; if (fileSize > MAX_FILE_SIZE) { skipped.push(`${relPath} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`); continue; } if (totalSize + fileSize > MAX_TOTAL_SIZE) { skipped.push(`${relPath} (would exceed ${MAX_TOTAL_SIZE / 1024 / 1024} MB total limit)`); continue; } const bytes = readFileSync(fullPath); totalSize += fileSize; if (isBinaryContent(bytes)) { files[relPath] = `base64:${bytes.toString('base64')}`; } else { files[relPath] = new TextDecoder().decode(bytes); } } } } await scanDir(dirPath); if (skipped.length > 0) { console.log(`[readDirFilesAsMap] Skipped ${skipped.length} file(s) exceeding size limits: ${skipped.join(', ')}`); } 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 /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 | 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 // ============================================================================= /** * Redact all env var values for safe logging. Only key names are preserved. */ function redactEnvVarsForLog(vars: Record): Record { const redacted: Record = {}; for (const key of Object.keys(vars)) { redacted[key] = '***'; } return redacted; } // ============================================================================= // UTILITIES // ============================================================================= /** * Get the compose stacks directory (always returns absolute path) */ export function getStacksDir(): string { if (_stacksDir) return _stacksDir; const dataDir = process.env.DATA_DIR || './data'; // Resolve to absolute path to avoid issues with relative paths in docker compose _stacksDir = resolve(join(dataDir, 'stacks')); if (!existsSync(_stacksDir)) { mkdirSync(_stacksDir, { recursive: true }); } return _stacksDir; } /** * Get stack directory path for a specific environment. * New stacks use: $DATA_DIR/stacks/// * Legacy stacks (no env): $DATA_DIR/stacks// * * Automatically looks up environment name from database. */ export async function getStackDir(stackName: string, envId?: number | null): Promise { const stacksDir = getStacksDir(); if (envId) { const env = await getEnvironment(envId); if (env) { return join(stacksDir, env.name, stackName); } } // Legacy path for stacks without environment return join(stacksDir, stackName); } /** * Resolve a path against the parent's realpath when the parent exists, so * symlinks resolve to their canonical location. We can't realpath the leaf * because the file may not exist yet (new stack). */ function resolveStackPath(input: string): string { const abs = resolve(input); const parent = dirname(abs); try { if (existsSync(parent)) { return join(realpathSync(parent), basename(abs)); } } catch { // realpath may fail on permission errors; fall through to the plain resolve. } return abs; } export interface StackPathValidation { ok: boolean; error?: string; resolved?: string; } /** * Validate that a custom compose or env file path is writable by this code * path. A path is accepted when: * - filename matches the stack-filename gate (.yml/.yaml/.env family) * - normalized form contains no .. segments (parent directory resolved * via realpath so a symlinked component can't smuggle traversal in) */ export async function validateStackPath(input: string): Promise { if (!input || typeof input !== 'string') { return { ok: false, error: 'Path is required' }; } const resolvedPath = resolveStackPath(input); // Normalized form must not contain a .. segment. const segments = pathNormalize(resolvedPath).split(pathSep); if (segments.includes('..')) { return { ok: false, error: 'Path traversal not allowed' }; } const filename = basename(resolvedPath); if (!isAllowedStackFilename(filename)) { return { ok: false, error: `File "${filename}" is not an allowed stack filename (must end in .yml, .yaml, or .env)` }; } return { ok: true, resolved: resolvedPath }; } /** * Find stack directory, checking paths in order: * 1. Database: Custom composePath in stackSources table (adopted/imported stacks) * 2. New path (envName): $DATA_DIR/stacks/// * 3. ID-based path (envId): $DATA_DIR/stacks/// * 4. Legacy path: $DATA_DIR/stacks// * * Automatically looks up environment name from database. * Always checks legacy path for backwards compatibility with pre-env stacks. */ export async function findStackDir(stackName: string, envId?: number | null): Promise { // 1. Check database for custom compose path first (adopted/imported stacks) const source = await getStackSource(stackName, envId); if (source?.composePath) { const customDir = dirname(source.composePath); if (existsSync(customDir)) { return customDir; } } const stacksDir = getStacksDir(); // Look up environment name if we have an ID if (envId) { const env = await getEnvironment(envId); // 2. Check new path (with envName) if (env) { const namePath = join(stacksDir, env.name, stackName); if (existsSync(namePath)) { return namePath; } } // 3. Check ID-based path const idPath = join(stacksDir, String(envId), stackName); if (existsSync(idPath)) { return idPath; } } // 4. Always check legacy path (stacks created before env-scoping was added) const legacyPath = join(stacksDir, stackName); if (existsSync(legacyPath)) { return legacyPath; } return null; } // ============================================================================= // COMPOSE FILE MANAGEMENT // ============================================================================= /** * Result type for getStackComposeFile */ export interface GetComposeFileResult { success: boolean; content?: string; stackDir?: string; error?: string; needsFileLocation?: boolean; composePath?: string | null; envPath?: string | null; suggestedEnvPath?: string; /** Stack source type (internal/git/external), from the stack_sources lookup already done here */ sourceType?: StackSourceType; } /** * Get compose file content for a stack. * * Unified logic for all stacks: * - If composePath is set in DB → use custom path * - If composePath is NULL → use default location (data/stacks/{env}/{name}/) * - If no source record and no files found → return needsFileLocation: true */ export async function getStackComposeFile( stackName: string, envId?: number | null, composeConfigPath?: string ): Promise { let source = await getStackSource(stackName, envId); // Fallback: try lookup by compose file path from Docker labels if (!source && composeConfigPath) { source = await getStackSourceByComposePath(composeConfigPath, envId); } // Case 1: Stack not in database = untracked (discovered from Docker but not imported) // User must select the compose file location - don't guess from default location if (!source) { return { success: false, needsFileLocation: true, error: `Select the compose file location for stack "${stackName}"` }; } // Case 2: Stack has custom composePath set - use it if (source.composePath) { try { if (!existsSync(source.composePath)) { return { success: false, error: `Compose file no longer accessible at ${source.composePath}. The file may have been moved or deleted.`, composePath: source.composePath, envPath: source.envPath }; } const content = readFileSync(source.composePath, 'utf-8'); const stackDir = dirname(source.composePath); // For custom paths, suggest .env next to compose if envPath not set let suggestedEnvPath: string | undefined; if (source.envPath === null) { suggestedEnvPath = source.composePath.replace(/\/[^/]+$/, '/.env'); } return { success: true, content, stackDir, composePath: source.composePath, envPath: source.envPath, suggestedEnvPath, sourceType: source.sourceType }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; return { success: false, error: `Failed to read compose file: ${message}`, composePath: source.composePath, envPath: source.envPath }; } } // Case 3: Stack is in DB but no custom path - check default location // This is for stacks created in Dockhand using the default data directory const stackDir = await findStackDir(stackName, envId); if (stackDir) { // Check all common compose file names (prefer new style first) const composeFileNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']; for (const fileName of composeFileNames) { const actualComposePath = join(stackDir, fileName); if (existsSync(actualComposePath)) { // Check for .env file in the same directory const envFilePath = join(stackDir, '.env'); const envExists = existsSync(envFilePath); return { success: true, content: readFileSync(actualComposePath, 'utf-8'), stackDir, // Always return the actual resolved paths for display composePath: actualComposePath, envPath: envExists ? envFilePath : null, sourceType: source.sourceType }; } } } // Case 4: Stack is in DB but compose file not found - need user to specify location return { success: false, needsFileLocation: true, error: `Select the compose file location for stack "${stackName}"` }; } /** * Save or create a stack compose file without deploying. * @param name - Stack name * @param content - Compose file content * @param create - If true, creates a new stack (fails if exists). If false, updates existing (fails if not exists). * @param envId - Environment ID for path scoping */ export async function saveStackComposeFile( name: string, content: string, create = false, envId?: number | null, options?: { composePath?: string; // Custom compose file path envPath?: string | null; // Custom env path (null = default, '' = none) moveFromDir?: string; // Old directory to move all files from when path changes oldComposePath?: string; // Old compose file path for renaming oldEnvPath?: string; // Old env file path for renaming secretProviderId?: number | null; // secret provider binding (undefined = unchanged) } ): Promise<{ success: boolean; error?: string }> { // Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores // Must also start with a letter or number if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { return { success: false, error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }; } // Check if this stack has a custom compose path configured, or if one was provided const source = await getStackSource(name, envId); const composePath = options?.composePath || source?.composePath; // Validate every caller-supplied or stored path before any disk write. // See validateStackPath() docs. const pathsToCheck = [ composePath, options?.envPath ?? source?.envPath, options?.oldComposePath, options?.oldEnvPath ].filter((p): p is string => !!p); for (const path of pathsToCheck) { const v = await validateStackPath(path); if (!v.ok) return { success: false, error: v.error }; } // Handle compose file move/rename when path changes if (options?.oldComposePath && options?.composePath && options.oldComposePath !== options.composePath && existsSync(options.oldComposePath)) { const newDir = dirname(options.composePath); // Ensure target directory exists if (!existsSync(newDir)) { try { mkdirSync(newDir, { recursive: true }); } catch (err: any) { console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`); } } // Move/rename the compose file to new location try { renameSync(options.oldComposePath, options.composePath); console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`); } catch (renameErr: any) { // If rename fails (e.g., cross-filesystem), try copy+delete if (renameErr.code === 'EXDEV') { try { const data = readFileSync(options.oldComposePath); writeFileSync(options.composePath, data); unlinkSync(options.oldComposePath); console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`); } catch (err: any) { console.warn(`[Stack] Failed to copy compose file: ${err.message}`); } } else { console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`); } } } // Handle env file move/rename when path changes if (options?.oldEnvPath && options?.envPath && options.oldEnvPath !== options.envPath && existsSync(options.oldEnvPath)) { const newDir = dirname(options.envPath); // Ensure target directory exists if (!existsSync(newDir)) { try { mkdirSync(newDir, { recursive: true }); } catch (err: any) { console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`); } } // Move/rename the env file to new location try { renameSync(options.oldEnvPath, options.envPath); console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`); } catch (renameErr: any) { // If rename fails (e.g., cross-filesystem), try copy+delete if (renameErr.code === 'EXDEV') { try { const data = readFileSync(options.oldEnvPath); writeFileSync(options.envPath, data); unlinkSync(options.oldEnvPath); console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`); } catch (err: any) { console.warn(`[Stack] Failed to copy env file: ${err.message}`); } } else { console.warn(`[Stack] Failed to move env file: ${renameErr.message}`); } } } // Move all files from old directory to new directory when path changes // Get the new directory from composePath const newDir = options?.composePath ? dirname(options.composePath) : null; if (options?.moveFromDir && newDir && options.moveFromDir !== newDir && existsSync(options.moveFromDir)) { try { // Ensure new directory exists if (!existsSync(newDir)) { mkdirSync(newDir, { recursive: true }); } // Move all files from old directory to new directory const files = readdirSync(options.moveFromDir); for (const file of files) { const oldFilePath = join(options.moveFromDir, file); const newFilePath = join(newDir, file); try { // Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem renameSync(oldFilePath, newFilePath); console.log(`[Stack] Moved file: ${oldFilePath} -> ${newFilePath}`); } catch (renameErr: any) { // If rename fails (e.g., cross-filesystem), try copy+delete if (renameErr.code === 'EXDEV') { const stat = statSync(oldFilePath); if (stat.isDirectory()) { // For directories, use recursive copy cpSync(oldFilePath, newFilePath, { recursive: true }); rmSync(oldFilePath, { recursive: true, force: true }); } else { // For files, read and write const data = readFileSync(oldFilePath); writeFileSync(newFilePath, data); unlinkSync(oldFilePath); } console.log(`[Stack] Copied file (cross-fs): ${oldFilePath} -> ${newFilePath}`); } else { throw renameErr; } } } // Remove old directory if it's now empty try { const remaining = readdirSync(options.moveFromDir); if (remaining.length === 0) { rmSync(options.moveFromDir, { recursive: true, force: true }); console.log(`[Stack] Removed empty old directory: ${options.moveFromDir}`); } } catch { // Ignore errors when checking/removing old directory } } catch (err: any) { console.warn(`[Stack] Failed to move files from ${options.moveFromDir} to ${newDir}: ${err.message}`); // Continue with save even if move fails - new files will be written anyway } } // If a custom composePath, envPath, or 1Password binding is being set (new or update), save it to the database if ( options?.composePath || options?.envPath !== undefined || options?.secretProviderId !== undefined ) { await upsertStackSource({ stackName: name, environmentId: envId ?? null, sourceType: 'internal', composePath: options?.composePath || source?.composePath || null, envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null), secretProviderId: options?.secretProviderId !== undefined ? options.secretProviderId : (source?.secretProviderId ?? null), }); } if (composePath) { // Write directly to the custom compose file path // Ensure parent directory exists for custom paths const parentDir = dirname(composePath); if (!existsSync(parentDir)) { try { mkdirSync(parentDir, { recursive: true }); } catch (err: any) { return { success: false, error: `Failed to create directory for compose file: ${err.message}` }; } } try { writeFileSync(composePath, content); return { success: true }; } catch (err: any) { return { success: false, error: `Failed to save compose file: ${err.message}` }; } } // For creates, use new path; for updates, find existing path first let stackDir: string; if (create) { stackDir = await getStackDir(name, envId); } else { const existingDir = await findStackDir(name, envId); if (!existingDir) { return { success: false, error: `Stack "${name}" not found` }; } stackDir = existingDir; } const composeFile = join(stackDir, 'compose.yaml'); const exists = existsSync(stackDir); if (create) { // Creating new stack - if directory exists, it's orphaned (clean it up) if (exists) { try { console.log(`Cleaning up orphaned stack directory: ${stackDir}`); rmSync(stackDir, { recursive: true, force: true }); } catch (err: any) { return { success: false, error: `Stack directory exists and cleanup failed: ${err.message}` }; } } try { mkdirSync(stackDir, { recursive: true }); } catch (err: any) { return { success: false, error: `Failed to create stack directory: ${err.message}` }; } } try { writeFileSync(composeFile, content); return { success: true }; } catch (err: any) { return { success: false, error: `Failed to ${create ? 'create' : 'save'} compose file: ${err.message}` }; } } // ============================================================================= // REGISTRY AUTHENTICATION // ============================================================================= /** * Login to all configured Docker registries before running compose commands. * This ensures that `docker compose up` can pull images from private registries. */ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', apiVersion?: string): Promise { const { getRegistries } = await import('./db.js'); const registries = await getRegistries(); if (registries.length === 0) { return; } const spawnEnv: Record = { ...(process.env as Record) }; if (dockerHost) { spawnEnv.DOCKER_HOST = dockerHost; } // Pass through explicit DOCKER_API_VERSION if provided by caller if (apiVersion) { spawnEnv.DOCKER_API_VERSION = apiVersion; } for (const reg of registries) { if (!reg.username || !reg.password) { continue; // Skip registries without credentials } try { // Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io') const { parseRegistryUrl } = await import('./docker.js'); const { host } = parseRegistryUrl(reg.url); const registryHost = host; console.log(`${logPrefix} Logging into registry: ${registryHost}`); const proc = nodeSpawn( 'docker', ['login', '-u', reg.username, '--password-stdin', registryHost], { env: spawnEnv, stdio: ['pipe', 'pipe', 'pipe'] } ); // Write password to stdin proc.stdin!.write(reg.password); proc.stdin!.end(); const { exitCode, stderr } = await collectProcess(proc); if (exitCode === 0) { console.log(`${logPrefix} Successfully logged into ${registryHost}`); } else { console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`); } } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); console.error(`${logPrefix} Error logging into registry ${reg.name}:`, errorMsg); } } } // ============================================================================= // COMPOSE COMMAND EXECUTION // ============================================================================= interface ComposeCommandOptions { stackName: string; envId?: number | null; forceRecreate?: boolean; build?: boolean; // Build images before starting (--build) noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build) pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never' removeVolumes?: boolean; stackFiles?: Record; // All files to send to Hawser /** Working directory for compose execution (for imported stacks) */ workingDir?: string; /** Full path to the compose file (for imported stacks, to avoid writing to internal dir) */ composePath?: string; /** Full path to the env file (for --env-file flag, supports custom names) */ envPath?: string; /** When true, write non-secret envVars to .env.dockhand override file (git stacks only) */ useOverrideFile?: boolean; /** Target specific service only (with --no-deps) for single-service updates */ serviceName?: string; /** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */ composeFileName?: string; /** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */ filesToDelete?: FileToDelete[]; /** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */ removeFiles?: boolean; } /** * Find a Docker Compose override file alongside the main compose file. * Docker Compose auto-discovers these when no -f flag is used, but when -f is required * we need to explicitly include the override file. */ function findComposeOverrideFile(stackDir: string, composeFileName: string): string | null { const overrideMap: Record = { 'compose.yaml': ['compose.override.yaml', 'compose.override.yml'], 'compose.yml': ['compose.override.yaml', 'compose.override.yml'], 'docker-compose.yaml': ['docker-compose.override.yaml', 'docker-compose.override.yml'], 'docker-compose.yml': ['docker-compose.override.yaml', 'docker-compose.override.yml'], }; const candidates = overrideMap[composeFileName] || []; for (const name of candidates) { const fullPath = join(stackDir, name); if (existsSync(fullPath)) return fullPath; } return null; } /** * Execute a docker compose command locally via child_process.spawn. * * Heads up on paths: `stackDir` is the cpSync target / fallback working * directory, but it's not always where the compose file lives — git stacks * with a contextDir can put the compose file in a subdirectory. Anything * compose-adjacent (spawn cwd, .env discovery, compose.override.yaml * lookup, .env.dockhand write, volume-path rewriter) anchors on * `composeFileDir = dirname(composeFile)`. The two are equal for the * common case and the change is transparent; only the subdir case is * affected. If you add anything new that touches a compose-adjacent file, * use `composeFileDir`, not `stackDir`. * * @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files) * @param envVars - Non-secret environment variables (from .env file, passed for backward compat) * @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk) * @param workingDir - Optional working directory for compose execution (for imported stacks) * @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing) */ async function executeLocalCompose( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', stackName: string, composeContent: string, dockerHost?: string, tlsConfig?: TlsConfig, envVars?: Record, secretVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean, envId?: number | null, workingDir?: string, customComposePath?: string, customEnvPath?: string, useOverrideFile?: boolean, serviceName?: string, build?: boolean, noBuildCache?: boolean, pullPolicy?: string, // direct-remote only: when the stack folder was staged to on the target // host, rewrite the compose's same-dir relative binds (`./x`) to /x so the // remote daemon binds the staged files. undefined = no staging, compose unchanged. remoteStackHostDir?: string ): Promise { const logPrefix = `[Stack:${stackName}]`; // Determine working directory and compose file path // For imported stacks (custom paths), use the provided workingDir and composePath // For internal stacks, use the default data directory let stackDir: string; let composeFile: string; if (customComposePath && workingDir) { // Custom compose path provided - use the provided working directory and compose file // This applies to: // - Imported/adopted stacks: files exist at original location, no copying needed // - Git stacks: files were already copied to workingDir by deployStack(), use them in-place // In both cases, we don't write the compose file - it already exists stackDir = workingDir; composeFile = customComposePath; } else { // Internal stack: use default data directory stackDir = operation === 'up' ? await getStackDir(stackName, envId) : (await findStackDir(stackName, envId) || await getStackDir(stackName, envId)); mkdirSync(stackDir, { recursive: true }); composeFile = join(stackDir, 'compose.yaml'); writeFileSync(composeFile, composeContent); } // Anchor for everything compose-adjacent: the directory the compose file // itself lives in. Equal to stackDir for the common case (compose at // stack root), but different when a git stack puts the compose file in // a subdirectory of the context dir. Bugs #1136 and #1139 both stemmed // from anchoring on stackDir instead of this. const composeFileDir = dirname(composeFile); // Rewrite relative volume paths for host path translation (in memory only, not saved to disk) // This is needed when Dockhand runs inside Docker - the Docker daemon on the host // can't see container paths like /app/data/..., so we translate them to host paths // Only do this for local Docker (no dockerHost) - for remote Docker the paths wouldn't make sense // Resolve relative paths against the COMPOSE FILE'S directory, not stackDir, so // subdir compose files with ./ and ../ binds resolve correctly (#1139). let finalComposeContent = composeContent; if (!dockerHost && getHostDataDir()) { const rewriteResult = rewriteComposeVolumePaths(composeContent, composeFileDir); if (rewriteResult.modified) { finalComposeContent = rewriteResult.content; console.log(`${logPrefix} [HostPath] Translating relative volume paths for Docker host:`); for (const change of rewriteResult.changes) { console.log(`${logPrefix} [HostPath]${change}`); } console.log(`${logPrefix} [HostPath] Translated compose content:`); console.log(`${logPrefix} [HostPath] ----------------------------------------`); for (const line of finalComposeContent.split('\n')) { console.log(`${logPrefix} [HostPath] ${line}`); } console.log(`${logPrefix} [HostPath] ----------------------------------------`); } } // direct-remote: the stack folder was copied to on the target host, so // rewrite same-dir relative binds (`./x`) to /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. const spawnEnv: Record = { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', HOME: process.env.HOME || '/root', }; // 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. if (process.env.DOCKER_API_VERSION) { 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)); // One-line audit of all path notions used below. Next time something is // off (compose can't find a file, volume bind points at the wrong // place, env vars don't reach the container), grep for "[PathAudit]" // in the log — the mismatch is usually obvious. The "subdir=yes" flag // is the canary for the case where stackDir and composeFileDir diverge. console.log( `${logPrefix} [PathAudit] ` + `stackDir=${stackDir} ` + `composeFile=${composeFile} ` + `composeFileDir=${composeFileDir} ` + `subdir=${composeFileDir !== stackDir ? 'yes' : 'no'} ` + `defaultEnvPath=${defaultEnvPath} (exists=${existsSync(defaultEnvPath)}) ` + `customEnvPath=${customEnvPath ?? '(none)'}` + (customEnvPath ? ` (exists=${existsSync(customEnvPath)})` : '') ); // LEGACY SUPPORT: Only inject envVars via shell if NO .env file exists // This is for stacks created with older Dockhand versions that stored env vars // in DB but didn't write .env files to disk. // For modern stacks with .env files, Docker Compose reads them via --env-file. if (!hasEnvFile && envVars) { Object.assign(spawnEnv, envVars); } // SECRET vars: always injected via shell env (NEVER written to .env files) if (secretVars) { Object.assign(spawnEnv, secretVars); } // Handle TLS certificates for remote Docker connections // Docker CLI requires file paths, so we write certs to a temp directory let tlsCertDir: string | undefined; if (tlsConfig && (tlsConfig.ca || tlsConfig.cert)) { // Create temp directory for TLS certs in DATA_DIR (guaranteed writable in Docker) // Use resolve() to get absolute path - docker compose runs from a different working dir const dataDir = resolve(process.env.DATA_DIR || './data'); tlsCertDir = join(dataDir, 'tmp', `tls-${stackName}-${Date.now()}`); mkdirSync(tlsCertDir, { recursive: true }); // Track for cleanup on unexpected process exit activeTlsDirs.add(tlsCertDir); // Write certs to files (docker-compose expects specific filenames) if (tlsConfig.ca) { const cleanedCa = cleanPem(tlsConfig.ca); if (cleanedCa) writeFileSync(join(tlsCertDir, 'ca.pem'), cleanedCa); } if (tlsConfig.cert) { const cleanedCert = cleanPem(tlsConfig.cert); if (cleanedCert) writeFileSync(join(tlsCertDir, 'cert.pem'), cleanedCert); } if (tlsConfig.key) { const cleanedKey = cleanPem(tlsConfig.key); if (cleanedKey) writeFileSync(join(tlsCertDir, 'key.pem'), cleanedKey); } // Set Docker TLS environment variables spawnEnv.DOCKER_TLS = '1'; spawnEnv.DOCKER_CERT_PATH = tlsCertDir; spawnEnv.DOCKER_TLS_VERIFY = tlsConfig.skipVerify ? '0' : '1'; console.log(`${logPrefix} TLS enabled: DOCKER_CERT_PATH=${tlsCertDir}, DOCKER_TLS_VERIFY=${spawnEnv.DOCKER_TLS_VERIFY}`); } // Build command based on operation // If we have modified compose content (host path translation), use stdin instead of file const useStdin = finalComposeContent !== composeContent; // `-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; if (useStdin) { // Host path translation: must pipe modified content via stdin args.push('-f', '-'); // Also include override file if it exists (needs path translation too) const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile)); if (overrideFile) { let overrideContent = readFileSync(overrideFile, 'utf-8'); if (getHostDataDir()) { const rewrite = rewriteComposeVolumePaths(overrideContent, composeFileDir); if (rewrite.modified) overrideContent = rewrite.content; } tempOverridePath = join(composeFileDir, '.compose.override.translated.yaml'); writeFileSync(tempOverridePath, overrideContent); args.push('-f', tempOverridePath); console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`); } } else if (customComposePath) { // Custom path (imported/adopted stacks): must use -f to point to non-standard location args.push('-f', composeFile); const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile)); if (overrideFile) { args.push('-f', overrideFile); console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`); } } // else: internal stack without path translation - no -f needed. // Docker Compose auto-discovers compose.yaml + compose.override.yaml from cwd. // Always auto-detect .env in compose directory (defaultEnvPath already defined above) if (existsSync(defaultEnvPath)) { args.push('--env-file', defaultEnvPath); } // Add custom env file if configured and different from auto-detected .env if (customEnvPath && resolve(customEnvPath) !== resolve(defaultEnvPath) && existsSync(customEnvPath)) { args.push('--env-file', customEnvPath); } // For git stacks: write non-secret overrides to .env.dockhand and add as second --env-file // Docker Compose applies env files in order, so later files override earlier ones. // This lets the repo's .env provide defaults while our overrides take precedence. // Secrets are still injected via shell env only (never written to disk). // Only written when useOverrideFile is true (git stacks). Internal/adopted stacks // already have their non-secrets in the .env file written by the UI. if (useOverrideFile && envVars && Object.keys(envVars).length > 0) { const overrideEnvPath = join(composeFileDir, '.env.dockhand'); const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n'; const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`); writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n'); args.push('--env-file', overrideEnvPath); } if (useStdin) { console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`); } switch (operation) { case 'up': args.push('up', '-d', '--remove-orphans'); if (forceRecreate) args.push('--force-recreate'); if (build) args.push('--build'); if (build && noBuildCache) args.push('--no-cache'); if (pullPolicy) args.push('--pull', pullPolicy); // If targeting a specific service, only update that service if (serviceName) { args.push(serviceName); } break; case 'down': args.push('down', '--remove-orphans'); if (removeVolumes) args.push('--volumes'); break; case 'stop': args.push('stop'); break; case 'start': args.push('start'); break; case 'restart': args.push('restart'); break; case 'pull': args.push('pull'); // If targeting a specific service, pull only that service if (serviceName) { args.push(serviceName); } break; } const commandStr = args.join(' '); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Operation:`, operation); console.log(`${logPrefix} Command:`, commandStr); console.log(`${logPrefix} Working directory:`, stackDir); console.log(`${logPrefix} Compose file:`, composeFile); console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)'); console.log(`${logPrefix} DOCKER_API_VERSION:`, spawnEnv.DOCKER_API_VERSION || '(not set - native negotiation)'); console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)'); console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0); if (envVars && Object.keys(envVars).length > 0) { console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(redactEnvVarsForLog(envVars), null, 2)); } // Login to registries before pulling images if (operation === 'up' || operation === 'pull') { await loginToRegistries(dockerHost, logPrefix, spawnEnv.DOCKER_API_VERSION); } try { console.log(`${logPrefix} Spawning docker compose process from ${composeFileDir}: ${args.join(' ')}`); const proc = nodeSpawn(args[0], args.slice(1), { cwd: composeFileDir, env: spawnEnv, stdio: [useStdin ? 'pipe' : 'inherit', 'pipe', 'pipe'] }); // If using stdin (host path translation), write the modified compose content if (useStdin && proc.stdin) { proc.stdin.write(finalComposeContent); proc.stdin.end(); } // Set up timeout with SIGTERM -> SIGKILL escalation let timedOut = false; const timeoutId = setTimeout(() => { timedOut = true; console.log(`${logPrefix} TIMEOUT: Process exceeded ${COMPOSE_TIMEOUT_MS / 1000} seconds, sending SIGTERM`); proc.kill('SIGTERM'); // Give process grace period to terminate cleanly before SIGKILL setTimeout(() => { try { proc.kill('SIGKILL'); console.log(`${logPrefix} TIMEOUT: Sent SIGKILL after grace period`); } catch { // Process may already be dead } }, COMPOSE_KILL_GRACE_MS); }, COMPOSE_TIMEOUT_MS); try { const { exitCode: code, stdout, stderr } = await collectProcess(proc); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Exit code:`, code); console.log(`${logPrefix} Timed out:`, timedOut); if (stdout) { console.log(`${logPrefix} STDOUT:`); console.log(stdout); } if (stderr) { console.log(`${logPrefix} STDERR:`); console.log(stderr); } if (timedOut) { return { success: false, output: stdout, error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds. If a service has a long stop_grace_period, raise the COMPOSE_TIMEOUT env var (seconds) above it.`, command: commandStr }; } if (code === 0) { return { success: true, output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`, command: commandStr }; } else { return { success: false, output: stdout, error: stderr || `docker compose ${operation} exited with code ${code}`, command: commandStr }; } } finally { clearTimeout(timeoutId); } } catch (err: any) { console.log(`${logPrefix} EXCEPTION in executeLocalCompose:`, err.message); return { success: false, output: '', error: `Failed to run docker compose ${operation}: ${err.message}`, command: commandStr }; } finally { // Cleanup temp override file from host path translation if (tempOverridePath) { try { unlinkSync(tempOverridePath); } catch { // Ignore cleanup errors } } // Cleanup TLS temp directory (always runs, even on exception) if (tlsCertDir) { activeTlsDirs.delete(tlsCertDir); try { rmSync(tlsCertDir, { recursive: true, force: true }); console.log(`${logPrefix} Cleaned up TLS temp directory: ${tlsCertDir}`); } catch { // Ignore cleanup errors } } } } /** * Execute a docker compose command via Hawser agent. * * @param envVars - Non-secret environment variables (from .env file) * @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env) */ async function executeComposeViaHawser( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', stackName: string, composeContent: string, envId: number, envVars?: Record, secretVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean, stackFiles?: Record, serviceName?: string, composeFileName?: string, build?: boolean, noBuildCache?: boolean, pullPolicy?: string, filesToDelete?: FileToDelete[], removeFiles?: boolean ): Promise { const logPrefix = `[Stack:${stackName}]`; // Import dockerFetch dynamically to avoid circular dependency const { dockerFetch } = await import('./docker.js'); // Merge envVars and secretVars for passing to Hawser // Hawser will inject ALL these as shell environment variables (secrets are NOT written to .env) const allEnvVars = { ...(envVars || {}), ...(secretVars || {}) }; const secretCount = secretVars ? Object.keys(secretVars).length : 0; console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Operation:`, operation); console.log(`${logPrefix} Environment ID:`, envId); console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)'); console.log(`${logPrefix} Compose filename:`, composeFileName ?? '(auto-detect)'); console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0); console.log(`${logPrefix} Secret env vars count:`, secretCount); if (allEnvVars && Object.keys(allEnvVars).length > 0) { console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(redactEnvVarsForLog(allEnvVars), null, 2)); } console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars'); console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0); if (stackFiles && Object.keys(stackFiles).length > 0) { console.log(`${logPrefix} Stack files:`, Object.keys(stackFiles).join(', ')); } try { // Build files map - include .env file ONLY for non-secret envVars // Secrets are passed separately via allEnvVars and injected via shell env const files: Record = { ...(stackFiles || {}) }; if (envVars && Object.keys(envVars).length > 0) { if (files['.env']) { // stackFiles already has .env (e.g., from git repo with comments) // Don't overwrite - the envVars are already passed separately for variable substitution console.log(`${logPrefix} Preserving existing .env from stackFiles (${files['.env'].length} chars), envVars passed separately for substitution`); } else { // No .env in stackFiles - generate one from NON-SECRET envVars only const envContent = Object.entries(envVars) .map(([key, value]) => `${key}=${value}`) .join('\n'); files['.env'] = envContent; console.log(`${logPrefix} Generated .env file with ${Object.keys(envVars).length} non-secret variables`); } } // Fetch registry credentials for Hawser to use for docker login const { getRegistries } = await import('./db.js'); const allRegistries = await getRegistries(); const registries = allRegistries .filter(r => r.username && r.password) .map(r => ({ url: r.url, username: r.username!, password: r.password! })); if (registries.length > 0) { console.log(`${logPrefix} Sending ${registries.length} registry credentials to Hawser`); } const body = JSON.stringify({ operation, projectName: stackName, composeFile: composeContent, composeFileName, // Explicit compose filename to use (e.g., "docker-compose.prod.yml") envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env files, // Files including .env (secrets NOT in .env file) forceRecreate: forceRecreate || false, removeVolumes: removeVolumes || false, build: build || false, noBuildCache: (build && noBuildCache) || false, pullPolicy: pullPolicy || '', registries, // Registry credentials for docker login serviceName, // Target specific service only (with --no-deps) // Git deletion sync (#966): agent re-verifies containment + content // hash per file before deleting. Old agents ignore this field. filesToDelete: filesToDelete && filesToDelete.length > 0 ? filesToDelete.map(f => ({ path: f.path, sha256: f.hash })) : undefined, // Stack deletion (#1162): remove the agent-side stack dir on down removeFiles: removeFiles || false }); console.log(`${logPrefix} Sending request to Hawser agent...`); const response = await dockerFetch( '/_hawser/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }, envId ); const result = (await response.json()) as { success: boolean; output?: string; error?: string; deletedFiles?: string[]; skippedFiles?: { path: string; reason: string }[]; }; console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} HAWSER RESPONSE`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Success:`, result.success); if (result.output) { console.log(`${logPrefix} Output:`, result.output); } if (result.error) { console.log(`${logPrefix} Error:`, result.error); } // Git deletion sync: interpret the agent's report. An agent that supports // the feature always returns deletedFiles/skippedFiles (possibly empty // arrays) when filesToDelete was sent. An old agent ignores the field and // returns neither — every requested deletion is marked agent-no-support. // Skips are FINAL (no carry-forward, no retry): the files stay on the // remote host as unmanaged residue, identical to pre-feature behavior. let deletion: DeletionApplyResult | undefined; if (filesToDelete && filesToDelete.length > 0) { if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) { deletion = { deleted: result.deletedFiles ?? [], skipped: (result.skippedFiles ?? []).map(s => ({ path: s.path, reason: normalizeSkipReason(s.reason || 'apply-failed') })) }; for (const path of deletion.deleted) { console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`); } for (const skip of deletion.skipped) { if (skip.reason === 'already-absent') continue; console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`); } } else { deletion = { deleted: [], skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason })) }; console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`); } } if (result.success) { return { success: true, output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`, deletion }; } else { return { success: false, output: result.output || '', error: result.error || `Compose ${operation} failed`, deletion }; } } catch (err: any) { console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message); const isStringLength = err.message?.includes('Invalid string length'); return { success: false, output: '', error: isStringLength ? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.` : `Failed to ${operation} via Hawser: ${err.message}` }; } } /** * Route compose command to appropriate executor based on connection type. * * @param envVars - Non-secret environment variables (from .env file) * @param secretVars - Secret environment variables (from DB, injected via shell env) */ async function executeComposeCommand( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', options: ComposeCommandOptions, composeContent: string, envVars?: Record, secretVars?: Record ): Promise { const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options; // Get environment configuration const env = envId ? await getEnvironment(envId) : null; if (!env) { // Local socket connection (no environment specified) return executeLocalCompose( operation, stackName, composeContent, undefined, // dockerHost undefined, // tlsConfig envVars, secretVars, forceRecreate, removeVolumes, envId, workingDir, composePath, envPath, useOverrideFile, serviceName, build, noBuildCache, pullPolicy ); } switch (env.connectionType) { case 'hawser-standard': case 'hawser-edge': { // For Hawser deployments, we need to read the .env file and send variables via envVars // because Docker Compose on the remote host may not auto-read the .env file reliably. // Local deployments use --env-file flag, but Hawser needs variables injected via shell env. let hawserEnvVars = envVars; if (envPath && existsSync(envPath)) { try { const envFileContent = readFileSync(envPath, 'utf-8'); const envFileVars = parseEnvFileContent(envFileContent, stackName); // Merge: envFileVars (lowest) < envVars (DB overrides) // secretVars are handled separately in executeComposeViaHawser hawserEnvVars = { ...envFileVars, ...(envVars || {}) }; console.log(`[Stack:${stackName}] Read ${Object.keys(envFileVars).length} vars from .env file for Hawser injection`); } catch (err) { console.warn(`[Stack:${stackName}] Failed to read .env file at ${envPath}:`, err); } } // Include compose override file if it exists alongside the compose file let hawserStackFiles = stackFiles; const composeDir = workingDir || (composePath ? dirname(composePath) : null); const composeBaseName = composePath ? basename(composePath) : 'compose.yaml'; if (composeDir) { const overridePath = findComposeOverrideFile(composeDir, composeBaseName); if (overridePath) { try { const overrideContent = readFileSync(overridePath, 'utf-8'); hawserStackFiles = { ...(hawserStackFiles || {}), [basename(overridePath)]: overrideContent }; console.log(`[Stack:${stackName}] Including override file for Hawser: ${basename(overridePath)}`); } catch (err) { console.warn(`[Stack:${stackName}] Failed to read override file at ${overridePath}:`, err); } } } // For git stacks: generate .env.dockhand with non-secret DB overrides // This mirrors executeLocalCompose behavior (lines 1017-1023). // envVars contains only the DB overrides (not merged repo .env values from hawserEnvVars). if (useOverrideFile && envVars && Object.keys(envVars).length > 0) { const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n'; const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`); hawserStackFiles = { ...(hawserStackFiles || {}), '.env.dockhand': header + lines.join('\n') + '\n' }; console.log(`[Stack:${stackName}] Including .env.dockhand override file for Hawser (${Object.keys(envVars).length} vars)`); } return executeComposeViaHawser( operation, stackName, composeContent, envId!, hawserEnvVars, secretVars, forceRecreate, removeVolumes, hawserStackFiles, serviceName, composeFileName, build, noBuildCache, pullPolicy, filesToDelete, removeFiles ); } case 'direct': { const port = env.port || 2375; const dockerHost = `tcp://${env.host}:${port}`; // Build TLS config if using HTTPS const tlsConfig: TlsConfig | undefined = env.protocol === 'https' ? { ca: env.tlsCa || undefined, cert: env.tlsCert || undefined, key: env.tlsKey || undefined, skipVerify: env.tlsSkipVerify ?? false } : 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 // /, 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 // / 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); // 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.hostDir && workingDir) { const { stageStackDirOnRemote } = await import('./stage-remote-stackfiles'); 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; } } return executeLocalCompose( operation, stackName, composeContent, dockerHost, tlsConfig, envVars, secretVars, forceRecreate, removeVolumes, envId, workingDir, composePath, envPath, useOverrideFile, serviceName, build, noBuildCache, pullPolicy, remoteStackHostDir ); } case 'socket': default: { // Honor the environment's configured socket path. Without this, // docker compose falls back to /var/run/docker.sock regardless of // the env's setting — wrong daemon for rootless/multi-socket hosts // (#1172). Default '/var/run/docker.sock' is left as undefined so // the CLI's own default applies (preserves existing behavior). const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock' ? `unix://${env.socketPath}` : undefined; return executeLocalCompose( operation, stackName, composeContent, sock, undefined, // tlsConfig envVars, secretVars, forceRecreate, removeVolumes, envId, workingDir, composePath, envPath, useOverrideFile, serviceName, build, noBuildCache, pullPolicy ); } } } // ============================================================================= // STACK DISCOVERY // ============================================================================= /** * List all compose stacks from Docker containers */ export async function listComposeStacks(envId?: number | null): Promise { // Import dynamically to avoid circular dependency const { listContainers } = await import('./docker.js'); const containers = await listContainers(true, envId); const stacks = new Map>(); // Container IDs with a pending DIGEST image update (the classic amber icon). // A persisted row can also be a pure newer-version-tag (semver) suggestion with // no digest update - those must NOT count as an "update available", so filter // on hasImageUpdate. `newerVersionIds` drives the separate semver stack badge. const pendingUpdateIds = new Set(); const newerVersionIds = new Set(); const newerVersionById = new Map(); if (typeof envId === 'number') { try { const pending = await getPendingContainerUpdates(envId); pending.forEach((p) => { if (p.hasImageUpdate) pendingUpdateIds.add(p.containerId); if (p.newerVersion) { newerVersionIds.add(p.containerId); try { newerVersionById.set(p.containerId, JSON.parse(p.newerVersion)); } catch { // malformed row - skip the badge for this one } } }); } catch { // Non-fatal: stacks just won't show update markers } } containers.forEach((container) => { const projectLabel = container.labels['com.docker.compose.project']; if (projectLabel) { if (!stacks.has(projectLabel)) { stacks.set(projectLabel, new Set()); } stacks.get(projectLabel)?.add(container.id); } }); const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => { const stackContainers = containers.filter((c) => containerIds.has(c.id)); const runningCount = stackContainers.filter((c) => c.state === 'running').length; // Containers that exited with code 0 are "completed" (e.g., init/migration containers) // and should not count against stack health const completedCount = stackContainers.filter((c) => c.state === 'exited' && c.exitCode === 0 ).length; const activeTotal = stackContainers.length - completedCount; const containerDetails: ContainerDetail[] = stackContainers .map((c) => { const service = c.labels['com.docker.compose.service'] || c.name; // Build ports with structured data for clickable links const ports = (c.ports || []) .filter((p) => p.PublicPort) .map((p) => ({ publicPort: p.PublicPort!, privatePort: p.PrivatePort, type: p.Type, display: `${p.PublicPort}:${p.PrivatePort}/${p.Type}` })); // Build networks with IP addresses const networks = Object.entries(c.networks || {}).map(([name, data]) => ({ name, ipAddress: data?.ipAddress || '' })); const volumeCount = c.mounts?.length || 0; return { id: c.id, name: c.name, service, state: c.state, status: c.status, health: c.health, image: c.image, ports, networks, volumeCount, restartCount: c.restartCount || 0, exitCode: c.exitCode, created: c.created, labels: c.labels || {}, updateAvailable: pendingUpdateIds.has(c.id), newerVersion: newerVersionById.get(c.id) ?? null }; }) .sort((a, b) => { const orderA = getOrderValue(a.labels); const orderB = getOrderValue(b.labels); if (orderA !== orderB) return orderA - orderB; return a.service.localeCompare(b.service); }); return { name, containers: Array.from(containerIds), containerDetails, updatesAvailable: stackContainers.some((c) => pendingUpdateIds.has(c.id)), updateCount: stackContainers.filter((c) => pendingUpdateIds.has(c.id)).length, // Newer-version-tag (semver) suggestions in this stack - drives the Tag badge. newerVersionCount: stackContainers.filter((c) => newerVersionIds.has(c.id)).length, status: activeTotal === 0 ? 'stopped' : runningCount >= activeTotal ? 'running' : runningCount === 0 ? 'stopped' : 'partial' }; }); return result; } /** * Get containers for a specific stack by label */ async function getStackContainers(stackName: string, envId?: number | null): Promise { const { listContainers } = await import('./docker.js'); const containers = await listContainers(true, envId); return containers.filter((c) => c.labels['com.docker.compose.project'] === stackName); } /** * Extract path hints from Docker container labels for a stack. * Docker Compose adds labels like: * - com.docker.compose.project.working_dir: /path/to/stack * - com.docker.compose.project.config_files: /path/to/docker-compose.yml[,...] */ export async function getStackPathHints( stackName: string, envId?: number | null ): Promise<{ workingDir: string | null; configFiles: string[] | null; }> { const containers = await getStackContainers(stackName, envId); if (containers.length === 0) { return { workingDir: null, configFiles: null }; } // Get labels from first container (all containers in stack have same project labels) const labels = containers[0].labels || {}; const workingDir = labels['com.docker.compose.project.working_dir'] || null; const configFilesRaw = labels['com.docker.compose.project.config_files'] || null; // Config files can be comma-separated if multiple compose files were used const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null; return { workingDir, configFiles }; } /** * Stop or remove orphan containers that belong to a stack but aren't defined in the compose file. * These are dynamically-spawned child containers (e.g., nextcloud-aio master creates worker containers). * Best-effort: errors are logged but don't fail the overall operation. */ async function cleanupOrphanStackContainers( stackName: string, envId: number | null | undefined, operation: 'stop' | 'remove' | 'restart' ): Promise { try { const containers = await getStackContainers(stackName, envId); const targets = containers.filter( (c) => c.state === 'running' || c.state === 'restarting' ); if (targets.length === 0) return; const { stopContainer, removeContainer, restartContainer } = await import('./docker.js'); const results = await Promise.allSettled( targets.map((c) => { if (operation === 'remove') return removeContainer(c.id, true, envId); if (operation === 'restart') return restartContainer(c.id, envId); return stopContainer(c.id, envId); }) ); const failures = results.filter((r) => r.status === 'rejected'); if (failures.length > 0) { console.warn( `[stacks] ${failures.length} orphan container(s) failed to ${operation} for stack "${stackName}"` ); } } catch (err) { console.warn(`[stacks] Failed to cleanup orphan containers for stack "${stackName}":`, err); } } /** * Helper to perform container-based operations for external stacks * Used as fallback when no compose file exists. * Uses Promise.allSettled for parallel execution. */ async function withContainerFallback( stackName: string, envId: number | null | undefined, operation: 'start' | 'stop' | 'restart' | 'remove' ): Promise { const { startContainer, stopContainer, restartContainer, removeContainer } = await import('./docker.js'); const containers = await getStackContainers(stackName, envId); if (containers.length === 0) { return { success: false, error: `No containers found for stack "${stackName}"` }; } // Execute all container operations in parallel // Note: listContainers returns containers with lowercase property names: id, name, labels const operationResults = await Promise.allSettled( containers.map(async (container) => { const containerName = container.name || container.id; switch (operation) { case 'start': await startContainer(container.id, envId); break; case 'stop': await stopContainer(container.id, envId); break; case 'restart': await restartContainer(container.id, envId); break; case 'remove': await removeContainer(container.id, true, envId); break; } return containerName; }) ); // Collect successes and failures const successes: string[] = []; const errors: string[] = []; operationResults.forEach((result, index) => { const containerName = containers[index].name || containers[index].id; if (result.status === 'fulfilled') { successes.push(result.value); } else { errors.push(`${containerName}: ${result.reason?.message || 'Unknown error'}`); } }); if (errors.length > 0) { return { success: successes.length > 0, error: errors.join('; '), output: successes.length > 0 ? `Partial success: ${successes.join(', ')}` : undefined }; } return { success: true, output: `${operation} completed for ${successes.length} container(s): ${successes.join(', ')}` }; } // ============================================================================= // STACK LIFECYCLE OPERATIONS // ============================================================================= /** * Result type for requireComposeFile - can indicate stack needs file location */ export interface RequireComposeResult { success: boolean; content?: string; secretVars?: Record; /** Non-secret variables from database (needed for compose interpolation) */ nonSecretVars?: Record; needsFileLocation?: boolean; error?: string; /** Directory containing the compose file (for working directory) */ stackDir?: string; /** Full path to the compose file (for imported stacks) */ composePath?: string; /** Full path to the env file (for --env-file flag) */ envPath?: string; /** Stack source type (internal/git/external), plumbed through from getStackComposeFile to avoid a redundant getStackSource lookup in callers */ sourceType?: StackSourceType; } /** * Get compose file and secret vars for stack operations. * * Returns: * - content: The compose file content * - secretVars: Secret variables (from DB only, for shell injection) * - envPath: Path to the .env file (Docker Compose reads non-secrets from it) * - needsFileLocation: true if stack needs user to specify file paths */ export async function requireComposeFile( stackName: string, envId?: number | null, composeConfigPath?: string ): Promise { const composeResult = await getStackComposeFile(stackName, envId, composeConfigPath); // If compose file not found, return info about what's needed if (!composeResult.success) { if (composeResult.needsFileLocation) { return { success: false, needsFileLocation: true, error: composeResult.error }; } return { success: false, error: composeResult.error || `Compose file not found for stack "${stackName}"` }; } // Get SECRET variables from database (for shell injection at runtime) // These are NEVER written to disk const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); // Get NON-SECRET variables from database (needed for compose interpolation) // For git stacks without .env files, these are the only source of env vars const nonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId); // Determine env file path for --env-file flag // For stacks with custom composePath (adopted/external), derive envPath from same directory // For internal stacks, use the default data directory let envFilePath: string | null = null; if (composeResult.composePath) { // Adopted/external stack with custom compose path if (composeResult.envPath) { // Explicit env path stored in database envFilePath = composeResult.envPath; } else if (composeResult.envPath === '') { // Explicitly no env file (user selected "no .env") envFilePath = null; } else { // envPath is null - look for .env next to the compose file envFilePath = join(dirname(composeResult.composePath), '.env'); } } else { // Internal stack - use default data directory location const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId); envFilePath = join(stackDir, '.env'); } // Docker Compose reads non-secrets from the .env file via --env-file. // Secrets and non-secrets from DB need to be injected via shell environment // for stacks without .env files (e.g., git stacks with manual env vars). return { success: true, content: composeResult.content!, secretVars, nonSecretVars, stackDir: composeResult.stackDir, composePath: composeResult.composePath ?? undefined, envPath: envFilePath ?? undefined, sourceType: composeResult.sourceType }; } /** * 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 { 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; // 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( 'up', { stackName, envId, workingDir: stackDir, composePath, envPath: hasEnv ? envPath : undefined, composeFileName, stackFiles }, composeContent, envVars, secretVars ); } /** * 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 { 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 ): Promise { const result = await requireComposeFile(stackName, envId); if (!result.success) { // No compose file - fall back to container-based operations const fallback = await withContainerFallback(stackName, envId, 'start'); await notifyStackLifecycle(stackName, envId, 'stack_started', fallback); return fallback; } // 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. const isGitStack = result.sourceType === 'git'; 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). // If no containers exist (stack was removed/down), use 'up' to create them. const containers = await getStackContainers(stackName, envId); const operation = containers.length > 0 ? 'start' : 'up'; if (operation === 'up') { await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`); } const startResult = await executeComposeCommand( operation, opts, result.content!, result.nonSecretVars, result.secretVars ); await notifyStackLifecycle(stackName, envId, 'stack_started', startResult); return startResult; } /** * Stop a stack using docker compose stop * Falls back to individual container stop for stacks without compose files */ export async function stopStack( stackName: string, envId?: number | null ): Promise { const result = await requireComposeFile(stackName, envId); if (!result.success) { // No compose file - fall back to container-based operations const fallback = await withContainerFallback(stackName, envId, 'stop'); await notifyStackLifecycle(stackName, envId, 'stack_stopped', fallback); return fallback; } // 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'; const composeResult = await executeComposeCommand( 'stop', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) }, result.content!, result.nonSecretVars, result.secretVars ); // 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; } /** * Restart a stack using docker compose restart or stop+up (recreate mode). * * mode='restart' (default): Uses 'docker compose restart' — fast, in-place restart * that preserves container IDs but won't fix stale network_mode references. * mode='recreate': Uses 'docker compose stop' then 'docker compose up -d' — * recreates containers, fixing network_mode: service: dependencies. * * Falls back to individual container restart for stacks without compose files. */ export async function restartStack( stackName: string, envId?: number | null, mode: 'restart' | 'recreate' = 'restart' ): Promise { const result = await requireComposeFile(stackName, envId); if (!result.success) { // No compose file - fall back to container-based operations return withContainerFallback(stackName, envId, 'restart'); } // Git stacks need useOverrideFile to write .env.dockhand with DB overrides. // Non-git stacks still pass nonSecretVars for legacy support (stacks without // .env files on disk get vars injected via shell env at executeLocalCompose). // 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, stackFiles: await lifecycleStackFiles(result.stackDir) }; let composeResult: StackOperationResult; if (mode === 'recreate') { // Stop first, then bring up with --force-recreate to ensure new container IDs await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars); await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`); composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars); } else { composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars); } // Restart any dynamically-spawned child containers not in the compose file await cleanupOrphanStackContainers(stackName, envId, 'restart'); return composeResult; } /** * Down a stack using docker compose down (removes containers, keeps files) * For stacks without compose files, this is equivalent to stop */ export async function downStack( stackName: string, envId?: number | null, removeVolumes = false ): Promise { const result = await requireComposeFile(stackName, envId); if (!result.success) { // No compose file - down is the same as stop return withContainerFallback(stackName, envId, 'stop'); } // 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'; const composeResult = await executeComposeCommand( 'down', { 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 ); // Remove any dynamically-spawned child containers not in the compose file await cleanupOrphanStackContainers(stackName, envId, 'remove'); return composeResult; } /** * Remove a stack completely (compose down + delete files + cleanup database) * Uses stack locking to prevent concurrent operations. */ /** * Compute exactly which on-disk directories a `removeStack(..., deleteFiles)` would delete, * WITHOUT deleting anything. The delete-preview endpoint uses this so the confirm modal * shows the user the same paths the backend will actually remove — one source of truth, no * frontend/backend drift (the class of bug behind #675). Adopted stacks whose files live * outside DATA_DIR are reported as `null` (Dockhand never deletes those). */ export async function computeStackDeletionPaths( stackName: string, envId?: number | null ): Promise<{ stackDir: string | null; gitDir: string | null; sourceType: string | null; namedVolumes: string[] }> { const stackSource = await getStackSource(stackName, envId); const stacksDir = getStacksDir(); // Named volumes compose created for this stack — exactly what `down --volumes` would // remove (compose-managed, labeled with the project). Best-effort: a docker/API hiccup // just yields an empty list (the delete still works; the modal just won't preview them). let namedVolumes: string[] = []; try { const { listVolumes } = await import('./docker.js'); const vols = await listVolumes(envId); namedVolumes = vols .filter((v) => v.labels?.['com.docker.compose.project'] === stackName) .map((v) => v.name) .sort(); } catch { /* best-effort */ } let stackDir: string | null = null; if (stackSource?.composePath) { const customDir = dirname(stackSource.composePath); // SAME strict guard as removeStack (#675): strict subdir + basename match. if (isDeletableStackDir(customDir, stacksDir, stackName) && existsSync(customDir)) { stackDir = customDir; } } if (!stackDir && !stackSource?.composePath) { const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); if (existsSync(defaultDir)) stackDir = defaultDir; } // Git stacks additionally have a cloned repo dir that removeStack deletes. let gitDir: string | null = null; const gitStack = await getGitStackByName(stackName, envId); if (gitStack) { try { const { getStackRepoPath } = await import('./git'); const repoPath = await getStackRepoPath(gitStack.id, gitStack.stackName, gitStack.environmentId); if (repoPath && existsSync(repoPath)) gitDir = repoPath; } catch { /* best-effort: no git dir shown if we can't resolve it */ } } return { stackDir, gitDir, sourceType: stackSource?.sourceType ?? null, namedVolumes }; } export async function removeStack( stackName: string, envId?: number | null, force = false, removeVolumes = false, deleteFiles = true ): Promise { return withStackLock(stackName, async () => { // 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); // If compose file exists, run docker compose down first if (composeResult.success) { const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId); const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); // Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand // explicitly lists. The list is the local staging dir contents — exactly // the files Dockhand ever wrote for this stack (compose, .env, // .env.dockhand, git files), never user volume data (that exists only on // the agent host). Each entry is hash-verified agent-side; the agent's // stack dir is removed only if nothing else remains in it. // Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks). let removalFiles: FileToDelete[] | undefined; if (composeResult.stackDir) { const resolvedStaging = resolve(composeResult.stackDir); if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) { removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map( ([path, hash]) => ({ path, hash }) ); } } const downResult = await executeComposeCommand( 'down', { stackName, envId, removeVolumes, 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 }, composeResult.content!, envVars, secretVars ); if (!downResult.success && !force) { return downResult; } // Remove any dynamically-spawned child containers not handled by compose await cleanupOrphanStackContainers(stackName, envId, 'remove'); // Local stack files ARE deleted below, but only under the DATA_DIR strict guard // (#675) - Dockhand owns that dir. A direct env's REMOTE staged dir has no such // guard: remote_stacks_dir is a user path that can hold co-located user data, and // nothing distinguishes a staged file from the user's own there. So we never delete // it - a stale compose is safe residue; an rm -rf could wipe user data. 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() ? 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 */ } } } else { // External stack - remove containers directly in parallel const { removeContainer } = await import('./docker.js'); const removalResults = await Promise.allSettled( stackContainers.map((container) => removeContainer(container.id, force, envId).then(() => container.name) ) ); const errors: string[] = []; removalResults.forEach((result, index) => { if (result.status === 'rejected') { const containerName = stackContainers[index].name || stackContainers[index].id; errors.push(`Failed to remove ${containerName}: ${result.reason?.message || 'Unknown error'}`); } }); if (errors.length > 0 && !force) { return { success: false, error: errors.join('; ') }; } } // Clean up auto-update schedules and pending updates for stack containers const envIdNum = typeof envId === 'number' ? envId : undefined; for (const container of stackContainers) { const containerName = container.names?.[0]?.replace(/^\//, '') || container.name; const containerId = container.id; // Clean up auto-update schedule try { const setting = await getAutoUpdateSetting(containerName, envIdNum); if (setting) { unregisterSchedule(setting.id, 'container_update'); await deleteAutoUpdateSchedule(containerName, envIdNum); } } catch { // Ignore cleanup errors } // Clean up pending container update try { if (envIdNum) { await removePendingContainerUpdate(envIdNum, containerId); } } catch { // Ignore cleanup errors } } // Clean up database records - collect errors but don't stop const cleanupErrors: string[] = []; // Delete compose file and directory // Only delete files that are within Dockhand's data directory (stacks we created) // Adopted/imported stacks have files outside DATA_DIR and should be preserved const stackSource = await getStackSource(stackName, envId); const stacksDir = getStacksDir(); // Determine what directory to delete (if any) let stackDir: string | null = null; if (stackSource?.composePath) { // Only delete if the compose dir is a STRICT SUBDIR of DATA_DIR/stacks/ whose // basename matches the stack name (#675 guard; see stack-delete-guard.ts). const customDir = dirname(stackSource.composePath); if (isDeletableStackDir(customDir, stacksDir, stackName) && existsSync(customDir)) { stackDir = customDir; } } // Fall back to default paths ONLY if no custom path was set in DB // (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR) if (!stackDir && !stackSource?.composePath) { const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); if (existsSync(defaultDir)) { stackDir = defaultDir; } } // Delete the directory if found — but ONLY when the caller asked to remove files. // "Remove stack" (deleteFiles=false) leaves the compose/.env/data on disk; "Remove // stack + files" (default) deletes them. if (stackDir && deleteFiles) { try { rmSync(stackDir, { recursive: true, force: true }); } catch (err: any) { console.error(`Failed to delete stack directory: ${err.message}`); cleanupErrors.push(`directory: ${err.message}`); } // Verify deletion succeeded (rmSync with force:true may not throw on some failures) if (existsSync(stackDir)) { const verifyErr = 'Directory still exists after deletion attempt'; console.error(`Failed to delete stack directory: ${verifyErr}`); cleanupErrors.push(`directory: ${verifyErr}`); } } try { await deleteStackSource(stackName, envId); } catch (err: any) { cleanupErrors.push(`stack source: ${err.message}`); } try { await deleteStackEnvVars(stackName, envId); } catch (err: any) { cleanupErrors.push(`env vars: ${err.message}`); } // If git stack, clean up git stack record. The DB record always goes (the stack is // gone); the cloned repo FILES on disk go only when deleteFiles is set. try { const gitStack = await getGitStackByName(stackName, envId); if (gitStack) { await deleteGitStack(gitStack.id); if (deleteFiles) await deleteGitStackFiles(gitStack.id, gitStack.stackName, gitStack.environmentId); } // Also cleanup any orphaned git stacks with NULL environment_id for this stack name if (envId !== undefined && envId !== null) { const orphanedGitStack = await getGitStackByName(stackName, null); if (orphanedGitStack) { await deleteGitStack(orphanedGitStack.id); if (deleteFiles) await deleteGitStackFiles(orphanedGitStack.id, orphanedGitStack.stackName, orphanedGitStack.environmentId); } } } catch (err: any) { cleanupErrors.push(`git stack: ${err.message}`); } // Check if directory deletion failed - this blocks stack recreation const directoryError = cleanupErrors.find(e => e.startsWith('directory:')); if (directoryError) { return { success: false, error: `Stack containers stopped but directory cleanup failed (${directoryError}). Cannot recreate stack with same name until directory is manually removed.` }; } // Return success with optional cleanup warnings for non-critical errors const output = cleanupErrors.length > 0 ? `Stack "${stackName}" removed with cleanup warnings: ${cleanupErrors.join('; ')}` : `Stack "${stackName}" removed successfully`; return { success: true, output }; }); } /** * 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 { 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 */ } } /** * After a pulled stack redeploy, clear the dashboard "pending update" rows for this * stack's containers now on the newest local image (#1311). Fire-and-forget: fully * wrapped so nothing here can affect the already-succeeded deploy; only DELETEs rows. */ async function reconcileStackPendingUpdates(stackName: string, envId: number): Promise { try { const pending = await getPendingContainerUpdates(envId); if (!pending || pending.length === 0) return; const { listContainers, getImageIdByTag } = await import('./docker.js'); const containers = await listContainers(true, envId); const live = containers.map((c) => ({ name: c.name, imageId: c.imageId, project: c.labels?.['com.docker.compose.project'] })); // Resolve each distinct pending tag to its newest local image id once. const tagCache = new Map(); for (const p of pending) { if (!tagCache.has(p.currentImage)) { try { tagCache.set(p.currentImage, await getImageIdByTag(p.currentImage, envId)); } catch { tagCache.set(p.currentImage, null); // unresolvable → keep (fail-safe) } } } const toClear = pendingRowsToClear(pending, live, (tag) => tagCache.get(tag) ?? null, stackName); for (const id of toClear) { await removePendingContainerUpdate(envId, id).catch(() => {}); } } catch { // Never let update-badge cleanup affect a deploy that already succeeded. } } /** * Deploy a stack (create or update) * Uses stack locking to prevent concurrent deployments. */ export async function deployStack(options: DeployStackOptions): Promise { const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete, isGitDeploy } = options; const logPrefix = `[Stack:${name}]`; console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} DEPLOY STACK START`); console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} Environment ID:`, envId ?? '(none - local)'); console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); console.log(`${logPrefix} Source directory:`, sourceDir ?? '(none)'); console.log(`${logPrefix} Custom compose path:`, composePath ?? '(none)'); console.log(`${logPrefix} Custom env path:`, envPath ?? '(none)'); console.log(`${logPrefix} Compose filename:`, composeFileName ?? '(none)'); console.log(`${logPrefix} Env filename:`, envFileName ?? '(none)'); // Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores // Must also start with a letter or number if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { console.log(`${logPrefix} ERROR: Invalid stack name format`); return { success: false, output: '', error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }; } return withStackLock(name, async () => { // Determine working directory: use custom composePath directory if provided, // otherwise fall back to internal stack directory let workingDir: string; let actualComposePath: string | undefined; let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks) let stackFiles: Record | undefined; let localDeletionResult: DeletionApplyResult | undefined; if (composePath) { // Adopted/imported stack: use the original compose file location // This ensures relative paths in the compose file resolve correctly // Files are NOT copied - we use them in-place at their original location workingDir = dirname(composePath); actualComposePath = composePath; console.log(`${logPrefix} Using custom compose path, workingDir:`, workingDir); } else if (sourceDir && existsSync(sourceDir)) { // Git stack: copy entire source directory to internal stack directory workingDir = await getStackDir(name, envId); // Set actualComposePath using the provided compose filename from git stack config if (composeFileName) { actualComposePath = join(workingDir, composeFileName); console.log(`${logPrefix} Using compose filename from git config:`, composeFileName); } else { // Detect compose file in source directory const composeNames = ['docker-compose.yaml', 'docker-compose.yml', 'compose.yaml', 'compose.yml']; for (const cn of composeNames) { if (existsSync(join(sourceDir, cn))) { actualComposePath = join(workingDir, cn); console.log(`${logPrefix} Detected compose file:`, cn); break; } } } // Set actualEnvPath using the provided env filename from git stack config // Only if envFileName is provided (env file is optional for git stacks) if (envFileName) { actualEnvPath = join(workingDir, envFileName); console.log(`${logPrefix} Using env filename from git config:`, envFileName); console.log(`${logPrefix} Actual env path will be:`, actualEnvPath); } // Read all files for Hawser deployments stackFiles = await readDirFilesAsMap(sourceDir); console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`); console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', ')); // Copy git source files to stack directory (overlay, not replace). // Do NOT rmSync first — relative volume mounts (e.g., ./data) live here // and would be destroyed, causing data loss (#831). console.log(`${logPrefix} Copying source directory to stack directory...`); mkdirSync(workingDir, { recursive: true }); cpSync(sourceDir, workingDir, { recursive: true, force: true, filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git') }); console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`); // Git deletion sync (#966): remove files that were deleted from the // repository. The list is manifest entries absent from the new clone; // the applier re-verifies containment + content hash per file, so // volume data and locally modified files are never touched. if (filesToDelete && filesToDelete.length > 0) { localDeletionResult = applyFileDeletions(workingDir, filesToDelete); for (const path of localDeletionResult.deleted) { console.log(`${logPrefix} Removed "${path}" — deleted from the repository`); } for (const skip of localDeletionResult.skipped) { if (skip.reason === 'already-absent') continue; console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`); } } } else { // Internal stack: check if a custom path exists in DB (adopted/imported stacks) const source = await getStackSource(name, envId); if (source?.composePath) { workingDir = dirname(source.composePath); actualComposePath = source.composePath; if (source.envPath) { actualEnvPath = source.envPath; } console.log(`${logPrefix} Using custom path from DB:`, workingDir); } 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); } } // For Hawser deployments: include compose and .env in stackFiles // Hawser writes files from the files map to disk at STACKS_DIR/{stackName}/ if (!stackFiles) { stackFiles = {}; } const composeFilename = actualComposePath ? basename(actualComposePath) : 'compose.yaml'; if (!stackFiles[composeFilename]) { stackFiles[composeFilename] = compose; console.log(`${logPrefix} Added ${composeFilename} to stackFiles for Hawser (${compose.length} chars)`); } let envFileContent: string | undefined = stackFiles['.env']; if (!envFileContent && actualEnvPath && existsSync(actualEnvPath)) { try { envFileContent = readFileSync(actualEnvPath, 'utf-8'); stackFiles['.env'] = envFileContent; console.log(`${logPrefix} Added .env to stackFiles for Hawser (${envFileContent.length} chars)`); } catch (err) { console.warn(`${logPrefix} Failed to read .env file at ${actualEnvPath}:`, err); } } console.log(`${logPrefix} Compose content length:`, compose.length, 'chars'); console.log(`${logPrefix} Compose content (full):`); console.log(compose); // Fetch overrides and secrets from DB const initialDbNonSecretVars = await getNonSecretEnvVarsAsRecord(name, envId); const initialSecretVars = await getSecretEnvVarsAsRecord(name, envId); // Add environment variables from 1Password const source = await getStackSource(name, envId); const { dbNonSecretVars, secretVars } = await resolveProviderEnvVars( initialDbNonSecretVars, initialSecretVars, logPrefix, source?.secretProviderId, 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); // For git stacks (sourceDir provided), use the override file (.env.dockhand) // to layer editor overrides on top of the repo's .env file. // Only DB overrides go into .env.dockhand - repo values are already in the repo's env file. // For internal/adopted stacks, the .env file is already the editor's output, // so no override file is needed - only pass secrets for shell injection. const isGitStack = !!sourceDir; console.log(`${logPrefix} Calling executeComposeCommand...`); const result = await executeComposeCommand( 'up', { stackName: name, envId, forceRecreate, build, noBuildCache, pullPolicy, stackFiles, workingDir, composePath: actualComposePath, envPath: actualEnvPath, useOverrideFile: isGitStack, // Pass compose filename for Hawser (extracted from path or provided explicitly) composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined), filesToDelete }, compose, isGitStack ? dbNonSecretVars : undefined, secretVars ); console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} DEPLOY STACK RESULT`); console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} Success:`, result.success); if (result.output) { console.log(`${logPrefix} Output:`, result.output); } if (result.error) { console.log(`${logPrefix} Error:`, result.error); } // Deletion result: the remote (Hawser) result is authoritative when present; // for local deployments the local applier's result is the truth. 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); // Clear stale pending-update badges (#1311). Fire-and-forget with a timeout so a // slow Docker API can't delay or affect the already-succeeded deploy. if (result.success && pullPolicy && typeof envId === 'number') { const envIdNum = envId; void Promise.race([ reconcileStackPendingUpdates(name, envIdNum), new Promise((resolve) => setTimeout(resolve, 15000)) ]).catch(() => {}); } return result; }); } /** * Pull images for a stack */ export async function pullStackImages( stackName: string, envId?: number | null ): Promise<{ success: boolean; output?: string; error?: string }> { const result = await requireComposeFile(stackName, envId); if (!result.success) { return { success: false, error: result.error || 'Compose file not found' }; } return executeComposeCommand( 'pull', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, result.nonSecretVars, result.secretVars ); } /** * Pull image for a specific service within a stack using docker compose pull . * This is the Compose-native approach to pulling images for auto-updates. * * @param stackName - The compose project name * @param serviceName - The service name to pull * @param envId - Optional environment ID * @returns Operation result */ export async function pullStackService( stackName: string, serviceName: string, envId?: number | null, composeConfigPath?: string ): Promise { const result = await requireComposeFile(stackName, envId, composeConfigPath); if (!result.success) { return { success: false, error: result.error || `Compose file not found for stack "${stackName}"` }; } return executeComposeCommand( 'pull', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, serviceName }, result.content!, result.nonSecretVars, result.secretVars ); } /** * Update a specific service within a stack using docker compose up -d --no-deps. * Docker Compose detects image changes naturally (the image is pulled beforehand), * so --force-recreate is not needed and can cause permission issues on bind mounts. * This preserves all compose configuration (static IPs, network aliases, etc.) while only * recreating the specified service when its image has changed. * * @param stackName - The compose project name * @param serviceName - The service name to update * @param envId - Optional environment ID * @returns Operation result */ export async function updateStackService( stackName: string, serviceName: string, envId?: number | null, composeConfigPath?: string ): Promise { const result = await requireComposeFile(stackName, envId, composeConfigPath); if (!result.success) { return { success: false, error: result.error || `Compose file not found for stack "${stackName}"` }; } await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`); // Don't use forceRecreate - Docker Compose will detect the image change // naturally since the image was already pulled before this function is called. // Using forceRecreate can cause permission issues on bind mounts. // This matches the behavior of: docker compose pull && docker compose up -d return executeComposeCommand( 'up', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, serviceName }, result.content!, result.nonSecretVars, result.secretVars ); } // ============================================================================= // ENVIRONMENT VARIABLE HELPERS // ============================================================================= /** * Save environment variables for a stack to the database (for secret tracking) */ export async function saveStackEnvVarsToDb( stackName: string, variables: { key: string; value: string; isSecret?: boolean }[], envId?: number | null ): Promise { await setStackEnvVars(stackName, envId ?? null, variables); } /** * Write environment variables to the .env file on disk (simple key=value format) * * WARNING: This generates a simple key=value file WITHOUT comments or formatting. * ONLY use during initial stack CREATION when no .env file exists. * * For EDITS, use PUT /api/stacks/[name]/env/raw which preserves the raw content * including all comments, formatting, and structure. */ export async function writeStackEnvFile( stackName: string, variables: { key: string; value: string; isSecret?: boolean }[], envId?: number | null, customEnvPath?: string ): Promise { if (customEnvPath) { const v = await validateStackPath(customEnvPath); if (!v.ok) throw new Error(v.error || 'Invalid env path'); } let envFilePath: string; if (customEnvPath) { envFilePath = customEnvPath; } else { // Check if stack has a custom path in DB const source = await getStackSource(stackName, envId); if (source?.envPath) { envFilePath = source.envPath; } else if (source?.composePath) { // Derive env path from custom compose path location envFilePath = join(dirname(source.composePath), '.env'); } else { // Fall back to default location envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); } } // Ensure parent directory exists const dir = dirname(envFilePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // SECURITY: Only write non-secret variables to .env file // Secrets are stored in DB and injected via shell environment at runtime const rawContent = variables .filter(v => v.key?.trim() && !v.isSecret) .map(v => `${v.key.trim()}=${v.value}`) .join('\n') + '\n'; writeFileSync(envFilePath, rawContent); } /** * Write raw environment content directly to the .env file (preserves comments/formatting) * * NOTE: Raw content should NOT contain secrets. Secrets are managed via the form view, * stored in DB, and injected via shell environment at runtime. */ export async function writeRawStackEnvFile( stackName: string, rawContent: string, envId?: number | null, customEnvPath?: string ): Promise { if (customEnvPath) { const v = await validateStackPath(customEnvPath); if (!v.ok) throw new Error(v.error || 'Invalid env path'); } let envFilePath: string; if (customEnvPath) { envFilePath = customEnvPath; } else { // Check if stack has a custom path in DB const source = await getStackSource(stackName, envId); if (source?.envPath) { envFilePath = source.envPath; } else if (source?.composePath) { // Derive env path from custom compose path location envFilePath = join(dirname(source.composePath), '.env'); } else { // Fall back to default location envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); } } // Ensure parent directory exists const dir = dirname(envFilePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(envFilePath, rawContent); } /** * Save environment variables for a stack (both to database and .env file) * * WARNING: Only use during initial stack CREATION - this generates a simple * key=value file that does NOT preserve comments or formatting. * * For EDITS, the StackModal saves to: * - PUT /api/stacks/[name]/env/raw (preserves raw content with comments) * - PUT /api/stacks/[name]/env (updates secret flags in DB only) */ export async function saveStackEnvVars( stackName: string, variables: { key: string; value: string; isSecret?: boolean }[], envId?: number | null, customEnvPath?: string ): Promise { // Save to database for secret tracking await saveStackEnvVarsToDb(stackName, variables, envId); // Write .env file to disk for Docker Compose await writeStackEnvFile(stackName, variables, envId, customEnvPath); } // ============================================================================= // SECRET PROVIDER INJECTION (deploy-time) // ============================================================================= // Resolves secrets from the stack's bound secret provider at deploy time and // merges them into the vars handed to `docker compose`. Two modes, depending on // what the provider supports: // - bulk pull: a whole environment / path of secrets, triggered by a selector // variable (OP_ENVIRONMENT_ID for 1Password back-compat, or the generic // DOCKHAND_SECRET_SELECTOR for any bulk-capable provider). // - inline references: values the provider recognises as references (e.g. // 1Password op://...), resolved in place. // The bound provider decides what a reference is and how to resolve it; nothing // here is 1Password-specific. Non-secret behaviour is untouched. interface EnrichedEnvVars { dbNonSecretVars: Record; secretVars: Record; // 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 // backward compatibility with the original 1Password integration. const BULK_SELECTOR_VARS = ['DOCKHAND_SECRET_SELECTOR', 'OP_ENVIRONMENT_ID']; async function resolveProviderEnvVars( dbNonSecretVars: Record, secretVars: Record, logPrefix: string, secretProviderId?: number | null, stackEnvFileContent?: string, persistTo?: { stackName: string; envId: number | null | undefined } ): Promise { 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; // --- Bulk pull (environment / path) -------------------------------------- // Priority: secrets > DB non-secrets > .env file (each overrides the previous) let selector: string | undefined; let selectorVar: string | undefined; for (const name of BULK_SELECTOR_VARS) { const v = secretVars[name] ?? dbNonSecretVars[name] ?? envFileVars[name]; if (v) { selector = v; selectorVar = name; break; } } if (selector && selectorVar) { try { if (providerRow && provider?.supportsBulk) { // Strip the selector var from the values passed to the stack delete secretVars[selectorVar]; delete dbNonSecretVars[selectorVar]; console.log(`${logPrefix} Resolving bulk selector via "${providerRow.name}" (${provider.label})`); const bulkVars = await provider.resolveBulk(providerRow.config, selector); console.log(`${logPrefix} ${provider.label} injected ${Object.keys(bulkVars).length} secret(s)`); // Bulk values merged underneath, with explicit DB secrets keeping priority secretVars = Object.assign(bulkVars, secretVars); } else if (!providerRow) { console.warn(`${logPrefix} ${selectorVar} is set but no secret provider is bound to this stack`); } else if (!provider) { console.warn(`${logPrefix} ${selectorVar} is set but bound provider type "${providerRow.type}" is not registered`); } else { console.warn(`${logPrefix} ${selectorVar} is set but provider "${providerRow.name}" (${provider.label}) does not support bulk pull`); } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`Failed to load secrets from provider: ${msg}`); } } // --- Inline references ---------------------------------------------------- // Only providers that support inline references detect any here. const isRef = (value: unknown): value is string => provider?.supportsReferences ? provider.isReference(value) : false; const envFileRefs = new Map(); for (const [key, value] of Object.entries(envFileVars)) { if (isRef(value)) { envFileRefs.set(key, value.trim()); } } const refs = new Set(); for (const value of Object.values(dbNonSecretVars)) { if (isRef(value)) refs.add(value.trim()); } for (const value of Object.values(secretVars)) { if (isRef(value)) refs.add(value.trim()); } for (const ref of envFileRefs.values()) refs.add(ref); if (refs.size === 0) { 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`); await persistInjectedKeys(); return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() }; } let refMap: Map; try { refMap = await provider.resolveSecretReferences(providerRow.config, Array.from(refs), logPrefix); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`Failed to resolve secret references: ${msg}`); } let promotedFromDb = 0; for (const [key, value] of Object.entries(dbNonSecretVars)) { if (isRef(value)) { const resolved = refMap.get(value.trim()); if (resolved !== undefined) { delete dbNonSecretVars[key]; secretVars[key] = resolved; promotedFromDb++; } } } for (const [key, value] of Object.entries(secretVars)) { if (isRef(value)) { const resolved = refMap.get(value.trim()); if (resolved !== undefined) { secretVars[key] = resolved; } } } let promotedFromEnvFile = 0; for (const [key, ref] of envFileRefs) { if (key in secretVars || key in dbNonSecretVars) continue; const resolved = refMap.get(ref); if (resolved !== undefined) { secretVars[key] = resolved; promotedFromEnvFile++; } } console.log(`${logPrefix} ${provider.label} resolved ${refMap.size}/${refs.size} reference(s) (promoted from DB: ${promotedFromDb}, from .env: ${promotedFromEnvFile})`); await persistInjectedKeys(); return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() }; } /** * Resolve the bound provider's secrets for a compose result produced by * requireComposeFile(). Mutates result.secretVars / result.nonSecretVars in * place so callers can pass the result through to executeComposeCommand * without further plumbing. */ async function applyProviderSecretsToComposeResult( result: RequireComposeResult, stackName: string, envId: number | null | undefined, logPrefix: string ): Promise { if (!result.success || !result.secretVars || !result.nonSecretVars) return; let envFileContent: string | undefined; if (result.envPath && existsSync(result.envPath)) { try { envFileContent = readFileSync(result.envPath, 'utf-8'); } catch (err) { console.warn(`${logPrefix} Failed to read .env at ${result.envPath}:`, err); } } const source = await getStackSource(stackName, envId ?? undefined); const { dbNonSecretVars, secretVars } = await resolveProviderEnvVars( { ...result.nonSecretVars }, { ...result.secretVars }, logPrefix, source?.secretProviderId, envFileContent, { stackName, envId: envId ?? undefined } ); result.nonSecretVars = dbNonSecretVars; result.secretVars = secretVars; } // ============================================================================= // RE-EXPORTS FOR BACKWARDS COMPATIBILITY // ============================================================================= // These exports maintain API compatibility with code that imports from docker.ts // They can be removed once all imports are updated export type { StackOperationResult as CreateStackResult };