/** * 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 } from 'node:fs'; import { join, resolve } from 'node:path'; import { getEnvironment, getStackEnvVarsAsRecord, getStackSource, upsertStackSource, deleteStackSource, getGitStackByName, deleteGitStack, getStackSources, deleteStackEnvVars } from './db'; import { deleteGitStackFiles } from './git'; // ============================================================================= // TYPES // ============================================================================= /** * Stack source types */ export type StackSourceType = 'internal' | 'git' | 'external'; /** * Stack operation result */ export interface StackOperationResult { success: boolean; output?: string; error?: string; } /** * 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; created: number; } /** * 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; envFileVars?: Record; forceRecreate?: boolean; } // ============================================================================= // ERRORS // ============================================================================= /** * Error for operations on external stacks without compose files */ export class ExternalStackError extends Error { public readonly stackName: string; constructor(stackName: string) { super( `Stack "${stackName}" was created outside of Dockhand. ` + `To manage this stack, first import it by clicking the Import button in the stack menu.` ); this.name = 'ExternalStackError'; this.stackName = stackName; } } /** * 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>(); /** * 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 const COMPOSE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL // ============================================================================= // DEBUG UTILITIES // ============================================================================= /** * Mask sensitive values in environment variables for safe logging. * Masks values for keys containing common secret patterns and truncates long values. */ function maskSecrets(vars: Record): Record { const masked: Record = {}; const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i; for (const [key, value] of Object.entries(vars)) { if (secretPatterns.test(key)) { masked[key] = '***'; } else if (value.length > 50) { // Truncate long values that might be secrets masked[key] = value.substring(0, 10) + '...(truncated)'; } else { masked[key] = value; } } return masked; } // ============================================================================= // 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; } /** * List stacks that have compose files stored locally */ export function listManagedStacks(): string[] { const stacksDir = getStacksDir(); if (!existsSync(stacksDir)) { return []; } return readdirSync(stacksDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .filter((dirent) => { const composeYml = join(stacksDir, dirent.name, 'docker-compose.yml'); const composeYaml = join(stacksDir, dirent.name, 'docker-compose.yaml'); return existsSync(composeYml) || existsSync(composeYaml); }) .map((dirent) => dirent.name); } // ============================================================================= // COMPOSE FILE MANAGEMENT // ============================================================================= /** * Get compose file content for a stack */ export async function getStackComposeFile( stackName: string ): Promise<{ success: boolean; content?: string; error?: string }> { const stacksDir = getStacksDir(); const stackDir = join(stacksDir, stackName); const composeFile = join(stackDir, 'docker-compose.yml'); const ymlFile = Bun.file(composeFile); if (await ymlFile.exists()) { return { success: true, content: await ymlFile.text() }; } const yamlFile = Bun.file(join(stackDir, 'docker-compose.yaml')); if (await yamlFile.exists()) { return { success: true, content: await yamlFile.text() }; } return { success: false, error: `Compose file not found for stack "${stackName}". The stack may have been created outside of Dockhand.` }; } /** * 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). */ export async function saveStackComposeFile( name: string, content: string, create = false ): Promise<{ success: boolean; error?: string }> { // Validate stack name if (!/^[a-zA-Z0-9_-]+$/.test(name)) { return { success: false, error: 'Stack name can only contain letters, numbers, hyphens, and underscores' }; } const stacksDir = getStacksDir(); const stackDir = join(stacksDir, name); const composeFile = join(stackDir, 'docker-compose.yml'); 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}` }; } } else { // Updating existing stack - must exist if (!exists) { return { success: false, error: `Stack "${name}" not found` }; } } try { await Bun.write(composeFile, content); return { success: true }; } catch (err: any) { return { success: false, error: `Failed to ${create ? 'create' : 'save'} compose file: ${err.message}` }; } } // ============================================================================= // COMPOSE COMMAND EXECUTION // ============================================================================= interface ComposeCommandOptions { stackName: string; envId?: number | null; forceRecreate?: boolean; removeVolumes?: boolean; } /** * Execute a docker compose command locally via Bun.spawn */ async function executeLocalCompose( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', stackName: string, composeContent: string, dockerHost?: string, envVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean ): Promise { const logPrefix = `[Stack:${stackName}]`; const stacksDir = getStacksDir(); const stackDir = join(stacksDir, stackName); mkdirSync(stackDir, { recursive: true }); const composeFile = join(stackDir, 'docker-compose.yml'); await Bun.write(composeFile, composeContent); const spawnEnv: Record = { ...(process.env as Record) }; if (dockerHost) { spawnEnv.DOCKER_HOST = dockerHost; } // Add stack-specific environment variables if (envVars) { Object.assign(spawnEnv, envVars); } // Build command based on operation const args = ['docker', 'compose', '-p', stackName, '-f', composeFile]; switch (operation) { case 'up': args.push('up', '-d', '--remove-orphans'); if (forceRecreate) args.push('--force-recreate'); break; case 'down': args.push('down'); 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'); break; } console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Operation:`, operation); console.log(`${logPrefix} Command:`, args.join(' ')); console.log(`${logPrefix} Working directory:`, stackDir); console.log(`${logPrefix} Compose file:`, composeFile); console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)'); console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); 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(maskSecrets(envVars), null, 2)); } try { console.log(`${logPrefix} Spawning docker compose process...`); const proc = Bun.spawn(args, { cwd: stackDir, env: spawnEnv, stdout: 'pipe', stderr: 'pipe' }); // 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 [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text() ]); const code = await proc.exited; 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 (code === 0) { return { success: true, output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully` }; } else { return { success: false, output: stdout, error: stderr || `docker compose ${operation} exited with code ${code}` }; } } 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}` }; } } /** * Execute a docker compose command via Hawser agent */ async function executeComposeViaHawser( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', stackName: string, composeContent: string, envId: number, envVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean ): Promise { const logPrefix = `[Stack:${stackName}]`; // Import dockerFetch dynamically to avoid circular dependency const { dockerFetch } = await import('./docker.js'); 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} Env vars count:`, envVars ? Object.keys(envVars).length : 0); if (envVars && Object.keys(envVars).length > 0) { console.log(`${logPrefix} Env vars being sent (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); } console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars'); try { const body = JSON.stringify({ operation, projectName: stackName, composeFile: composeContent, envVars: envVars || {}, forceRecreate: forceRecreate || false, removeVolumes: removeVolumes || 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; }; 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); } if (result.success) { return { success: true, output: result.output || `Stack "${stackName}" ${operation} completed via Hawser` }; } else { return { success: false, output: result.output || '', error: result.error || `Compose ${operation} failed` }; } } catch (err: any) { console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message); return { success: false, output: '', error: `Failed to ${operation} via Hawser: ${err.message}` }; } } /** * Route compose command to appropriate executor based on connection type */ async function executeComposeCommand( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', options: ComposeCommandOptions, composeContent: string, envVars?: Record ): Promise { const { stackName, envId, forceRecreate, removeVolumes } = 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, envVars, forceRecreate, removeVolumes ); } switch (env.connectionType) { case 'hawser-standard': case 'hawser-edge': return executeComposeViaHawser( operation, stackName, composeContent, envId!, envVars, forceRecreate, removeVolumes ); case 'direct': { const port = env.port || 2375; const dockerHost = `tcp://${env.host}:${port}`; return executeLocalCompose( operation, stackName, composeContent, dockerHost, envVars, forceRecreate, removeVolumes ); } case 'socket': default: return executeLocalCompose( operation, stackName, composeContent, undefined, envVars, forceRecreate, removeVolumes ); } } // ============================================================================= // 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>(); 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; 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, created: c.created }; }) .sort((a, b) => a.service.localeCompare(b.service)); return { name, containers: Array.from(containerIds), containerDetails, status: runningCount === stackContainers.length ? '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); } /** * 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 // ============================================================================= /** * Ensure we have a compose file for operations, throw appropriate error if not */ async function requireComposeFile( stackName: string, envId?: number | null ): Promise<{ content: string; envVars: Record }> { const composeResult = await getStackComposeFile(stackName); if (!composeResult.success) { // Check if this is an external stack const source = await getStackSource(stackName, envId); if (!source || source.sourceType === 'external') { throw new ExternalStackError(stackName); } throw new ComposeFileNotFoundError(stackName); } // Get environment variables from database const envVars = await getStackEnvVarsAsRecord(stackName, envId); return { content: composeResult.content!, envVars }; } /** * Start a stack using docker compose up * Falls back to individual container start for external stacks */ export async function startStack( stackName: string, envId?: number | null ): Promise { try { const { content, envVars } = await requireComposeFile(stackName, envId); return executeComposeCommand('up', { stackName, envId }, content, envVars); } catch (err) { if (err instanceof ExternalStackError) { return withContainerFallback(stackName, envId, 'start'); } throw err; } } /** * Stop a stack using docker compose stop * Falls back to individual container stop for external stacks */ export async function stopStack( stackName: string, envId?: number | null ): Promise { try { const { content, envVars } = await requireComposeFile(stackName, envId); return executeComposeCommand('stop', { stackName, envId }, content, envVars); } catch (err) { if (err instanceof ExternalStackError) { return withContainerFallback(stackName, envId, 'stop'); } throw err; } } /** * Restart a stack using docker compose restart * Falls back to individual container restart for external stacks */ export async function restartStack( stackName: string, envId?: number | null ): Promise { try { const { content, envVars } = await requireComposeFile(stackName, envId); return executeComposeCommand('restart', { stackName, envId }, content, envVars); } catch (err) { if (err instanceof ExternalStackError) { return withContainerFallback(stackName, envId, 'restart'); } throw err; } } /** * Down a stack using docker compose down (removes containers, keeps files) * For external stacks, this is equivalent to stop (no compose file to "down") */ export async function downStack( stackName: string, envId?: number | null, removeVolumes = false ): Promise { try { const { content, envVars } = await requireComposeFile(stackName, envId); return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars); } catch (err) { if (err instanceof ExternalStackError) { // For external stacks, down is the same as stop (no compose file to tear down) return withContainerFallback(stackName, envId, 'stop'); } throw err; } } /** * Remove a stack completely (compose down + delete files + cleanup database) * Uses stack locking to prevent concurrent operations. */ export async function removeStack( stackName: string, envId?: number | null, force = false ): Promise { return withStackLock(stackName, async () => { // Get compose file (may not exist for external stacks) const composeResult = await getStackComposeFile(stackName); // If compose file exists, run docker compose down first if (composeResult.success) { const envVars = await getStackEnvVarsAsRecord(stackName, envId); const downResult = await executeComposeCommand( 'down', { stackName, envId }, composeResult.content!, envVars ); if (!downResult.success && !force) { return downResult; } } else { // External stack - remove containers directly in parallel const { removeContainer } = await import('./docker.js'); const stackContainers = await getStackContainers(stackName, envId); 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 database records - collect errors but don't stop const cleanupErrors: string[] = []; // Delete compose file and directory const stacksDir = getStacksDir(); const stackDir = join(stacksDir, stackName); if (existsSync(stackDir)) { 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 try { const gitStack = await getGitStackByName(stackName, envId); if (gitStack) { await deleteGitStack(gitStack.id); deleteGitStackFiles(gitStack.id); } // 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); deleteGitStackFiles(orphanedGitStack.id); } } } 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 }; }); } /** * Deploy a stack (create or update) * Uses stack locking to prevent concurrent deployments. */ export async function deployStack(options: DeployStackOptions): Promise { const { name, compose, envId, envFileVars, forceRecreate } = 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} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0); if (envFileVars && Object.keys(envFileVars).length > 0) { console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', ')); console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2)); } // Validate stack name if (!/^[a-zA-Z0-9_-]+$/.test(name)) { console.log(`${logPrefix} ERROR: Invalid stack name format`); return { success: false, output: '', error: 'Stack name can only contain letters, numbers, hyphens, and underscores' }; } return withStackLock(name, async () => { // Ensure stack directory exists and write compose file (for local reference) const stacksDir = getStacksDir(); const stackDir = join(stacksDir, name); mkdirSync(stackDir, { recursive: true }); const composeFile = join(stackDir, 'docker-compose.yml'); await Bun.write(composeFile, compose); console.log(`${logPrefix} Compose file written to:`, composeFile); console.log(`${logPrefix} Compose content length:`, compose.length, 'chars'); console.log(`${logPrefix} Compose content (full):`); console.log(compose); // Fetch stack environment variables from database (these are user overrides) const dbEnvVars = await getStackEnvVarsAsRecord(name, envId); console.log(`${logPrefix} DB env vars count:`, Object.keys(dbEnvVars).length); if (Object.keys(dbEnvVars).length > 0) { console.log(`${logPrefix} DB env var keys:`, Object.keys(dbEnvVars).join(', ')); console.log(`${logPrefix} DB env vars (masked):`, JSON.stringify(maskSecrets(dbEnvVars), null, 2)); } // Merge: env file vars as base, database overrides take precedence const envVars = { ...envFileVars, ...dbEnvVars }; console.log(`${logPrefix} Merged env vars count:`, Object.keys(envVars).length); if (Object.keys(envVars).length > 0) { console.log(`${logPrefix} Merged env var keys:`, Object.keys(envVars).join(', ')); console.log(`${logPrefix} Merged env vars (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); } console.log(`${logPrefix} Calling executeComposeCommand...`); const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate }, compose, envVars); 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); } return result; }); } /** * Pull images for a stack */ export async function pullStackImages( stackName: string, envId?: number | null ): Promise<{ success: boolean; output?: string; error?: string }> { const { content, envVars } = await requireComposeFile(stackName, envId); return executeComposeCommand('pull', { stackName, envId }, content, envVars); } // ============================================================================= // 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 };