This commit is contained in:
jarek committed 2026-01-17 15:06:14 +01:00
1 parent 62636426bf
commit c185d00dc3
60 files changed
+2830 -918

No files matched your search

+2 -2
View File
@@ -1911,7 +1911,7 @@ export async function createGitRepository(data: {
name: data.name,
url: data.url,
branch: data.branch || 'main',
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
credentialId: data.credentialId || null,
environmentId: data.environmentId || null,
autoUpdate: data.autoUpdate || false,
@@ -2325,7 +2325,7 @@ export async function createGitStack(data: {
stackName: data.stackName,
environmentId: data.environmentId ?? null,
repositoryId: data.repositoryId,
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
+2 -2
View File
@@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
environmentId: integer('environment_id'),
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -308,7 +308,7 @@ export const gitStacks = sqliteTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
+2 -2
View File
@@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
environmentId: integer('environment_id'),
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -311,7 +311,7 @@ export const gitStacks = pgTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
+288 -122
View File
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { isSystemContainer } from './scheduler/tasks/update-utils';
/**
* Custom error for when an environment is not found.
@@ -736,7 +737,8 @@ export async function listContainers(all = true, envId?: number | null): Promise
restartCount: restartCounts.get(container.Id) || 0,
mounts,
labels: container.Labels || {},
command: container.Command || ''
command: container.Command || '',
systemContainer: isSystemContainer(container.Image || '')
};
});
}
@@ -1204,6 +1206,12 @@ function parseImageReference(imageName: string): { registry: string; repo: strin
}
}
// Normalize docker.io to index.docker.io (Docker Hub's actual registry host)
// docker.io redirects to www.docker.com, while index.docker.io is the real API
if (registry === 'docker.io') {
registry = 'index.docker.io';
}
// Docker Hub requires library/ prefix for official images
if (registry === 'index.docker.io' && !repo.includes('/')) {
repo = `library/${repo}`;
@@ -1349,6 +1357,141 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
}
}
/**
* Get authentication header for registry API requests.
* Handles the Docker Registry V2 OAuth2 token flow:
* 1. Challenge request to /v2/ to get WWW-Authenticate header
* 2. Parse realm, service from challenge
* 3. Request token from realm with credentials (if available)
*
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io')
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
* @param credentials - Optional credentials { username, password }
* @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null
*/
export async function getRegistryAuthHeader(
registryUrl: string,
scope: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
// Normalize URL
let baseUrl = registryUrl;
if (!baseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`;
}
baseUrl = baseUrl.replace(/\/$/, '');
// Step 1: Challenge request to /v2/
const challengeResponse = await fetch(`${baseUrl}/v2/`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' }
});
// If 200, no auth needed
if (challengeResponse.ok) {
return null;
}
// If not 401, something else is wrong
if (challengeResponse.status !== 401) {
console.error(`Registry challenge failed: ${challengeResponse.status}`);
return null;
}
// Step 2: Parse WWW-Authenticate header
const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || '';
const challenge = wwwAuth.toLowerCase();
if (challenge.startsWith('basic')) {
// Basic auth - use credentials if we have them
if (credentials) {
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
return `Basic ${basicAuth}`;
}
return null;
}
if (!challenge.startsWith('bearer')) {
console.error(`Unsupported auth type: ${wwwAuth}`);
return null;
}
// Parse bearer challenge: Bearer realm="...",service="...",scope="..."
const realmMatch = wwwAuth.match(/realm="([^"]+)"/i);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/i);
if (!realmMatch) {
console.error('No realm in WWW-Authenticate header');
return null;
}
const realm = realmMatch[1];
const service = serviceMatch ? serviceMatch[1] : '';
// Step 3: Request token from realm (with credentials if available)
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
// Add Basic auth header if we have credentials
if (credentials) {
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
tokenHeaders['Authorization'] = `Basic ${basicAuth}`;
}
const tokenResponse = await fetch(tokenUrl.toString(), {
headers: tokenHeaders
});
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text().catch(() => '');
console.error(`Token request failed: ${tokenResponse.status} - ${errorBody}`);
return null;
}
const tokenData = await tokenResponse.json() as { token?: string; access_token?: string };
const token = tokenData.token || tokenData.access_token || null;
return token ? `Bearer ${token}` : null;
} catch (e) {
console.error('Failed to get registry auth header:', e);
return null;
}
}
/**
* Helper to get normalized registry URL and auth header for registry API requests.
* Combines URL normalization, credential extraction, and token flow in one call.
*
* @param registry - Registry object from database
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
* @returns { baseUrl, authHeader } - Normalized URL and auth header (or null)
*/
export async function getRegistryAuth(
registry: { url: string; username?: string | null; password?: string | null },
scope: string
): Promise<{ baseUrl: string; authHeader: string | null }> {
// Normalize URL
let baseUrl = registry.url;
if (!baseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`;
}
baseUrl = baseUrl.replace(/\/$/, '');
// Get auth header using proper token flow
const credentials = registry.username && registry.password
? { username: registry.username, password: registry.password }
: null;
const authHeader = await getRegistryAuthHeader(baseUrl, scope, credentials);
return { baseUrl, authHeader };
}
/**
* Check the registry for the current manifest digest of an image.
* Simple HEAD request to get Docker-Content-Digest header.
@@ -2161,14 +2304,15 @@ export async function runContainer(options: {
binds?: string[];
env?: string[];
name?: string;
autoRemove?: boolean;
envId?: number | null;
}): Promise<{ stdout: string; stderr: string }> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-temp-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
// Create container
// Create container - disable AutoRemove since we fetch logs after exit
// and clean up manually. AutoRemove causes race condition where container
// is removed before we can fetch logs.
const containerConfig: any = {
Image: options.image,
Cmd: options.cmd,
@@ -2176,7 +2320,7 @@ export async function runContainer(options: {
Tty: false,
HostConfig: {
Binds: options.binds || [],
AutoRemove: options.autoRemove !== false
AutoRemove: false // Never use AutoRemove - we clean up manually after fetching logs
}
};
@@ -2190,15 +2334,21 @@ export async function runContainer(options: {
);
const containerId = createResult.Id;
console.log(`[runContainer] Created container ${containerId} for image ${options.image}`);
try {
// Start container
console.log(`[runContainer] Starting container ${containerId}...`);
await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
// Wait for container to finish
await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
console.log(`[runContainer] Waiting for container ${containerId} to finish...`);
const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
const waitResult = await waitResponse.json().catch(() => ({}));
console.log(`[runContainer] Container ${containerId} finished with exit code:`, waitResult?.StatusCode);
// Get logs
// Get logs - container is stopped but NOT removed yet since AutoRemove is false
console.log(`[runContainer] Fetching logs for container ${containerId}...`);
const logsResponse = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=true`,
{},
@@ -2206,15 +2356,20 @@ export async function runContainer(options: {
);
const buffer = Buffer.from(await logsResponse.arrayBuffer());
return demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string };
console.log(`[runContainer] Got logs buffer, size: ${buffer.length} bytes`);
const result = demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string };
console.log(`[runContainer] Demuxed: stdout=${result.stdout.length} chars, stderr=${result.stderr.length} chars`);
if (result.stdout.length === 0 && result.stderr.length === 0 && buffer.length > 0) {
console.log(`[runContainer] WARNING: Buffer has data but demux returned empty. First 100 bytes:`, buffer.slice(0, 100));
}
return result;
} finally {
// Cleanup container if not auto-removed
if (options.autoRemove === false) {
try {
await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
} catch {
// Ignore cleanup errors
}
// Always cleanup container manually
try {
await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
} catch {
// Ignore cleanup errors
}
}
}
@@ -2230,11 +2385,10 @@ export async function runContainerWithStreaming(options: {
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
}): Promise<string> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
// Create container
// Create container WITHOUT AutoRemove - we need to fetch logs after it exits
const containerConfig: any = {
Image: options.image,
Cmd: options.cmd,
@@ -2242,123 +2396,135 @@ export async function runContainerWithStreaming(options: {
Tty: false,
HostConfig: {
Binds: options.binds || [],
AutoRemove: true
AutoRemove: false
}
};
// Try to create container, handle 409 conflict by removing stale container
let createResult: { Id: string };
try {
createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
method: 'POST',
body: JSON.stringify(containerConfig)
},
options.envId
);
} catch (error: any) {
// Check for 409 conflict (container name already in use)
if (error?.message?.includes('409') || error?.status === 409) {
console.log(`[Docker] Container name conflict for ${containerName}, attempting cleanup...`);
// Try to force remove the conflicting container
try {
await dockerFetch(`/containers/${containerName}?force=true`, { method: 'DELETE' }, options.envId);
console.log(`[Docker] Removed stale container ${containerName}`);
} catch (removeError) {
console.error(`[Docker] Failed to remove stale container:`, removeError);
}
// Retry with a new random suffix
const retryName = `${baseName}-${randomSuffix()}`;
createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(retryName)}`,
{
method: 'POST',
body: JSON.stringify(containerConfig)
},
options.envId
);
} else {
throw error;
}
}
const containerId = createResult.Id;
// Start container
await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
// Check if this is an edge environment for streaming approach
const config = await getDockerConfig(options.envId ?? undefined);
// Stream logs while container is running
if (config.connectionType === 'hawser-edge' && config.environmentId) {
// Edge mode: use sendEdgeStreamRequest for real-time streaming
return new Promise<string>((resolve, reject) => {
let stdout = '';
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const { cancel } = sendEdgeStreamRequest(
config.environmentId!,
'GET',
`/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`,
{
onData: (data: string) => {
try {
// Data is base64 encoded from edge agent
const decoded = Buffer.from(data, 'base64');
buffer = Buffer.concat([buffer, decoded]);
// Process Docker stream frames
const result = processStreamFrames(buffer, options.onStdout, options.onStderr);
stdout += result.stdout;
buffer = result.remaining;
} catch {
// If not base64, try as raw data
const result = processStreamFrames(Buffer.from(data), options.onStdout, options.onStderr);
stdout += result.stdout;
}
},
onEnd: () => {
resolve(stdout);
},
onError: (error: string) => {
// If container finished, treat as success
if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) {
resolve(stdout);
} else {
reject(new Error(error));
}
}
}
);
});
}
// Non-edge mode: use regular streaming
const logsResponse = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`,
{ streaming: true },
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
options.envId
);
let stdout = '';
const reader = logsResponse.body?.getReader();
if (reader) {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const containerId = createResult.Id;
const config = await getDockerConfig(options.envId ?? undefined);
while (true) {
const { done, value } = await reader.read();
if (done) break;
try {
// Start container
await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
buffer = Buffer.concat([buffer, Buffer.from(value)]);
const result = processStreamFrames(buffer, options.onStdout, options.onStderr);
stdout += result.stdout;
buffer = result.remaining;
// Stream stderr for real-time progress while container runs
if (config.connectionType === 'hawser-edge' && config.environmentId) {
await streamEdgeStderr(config.environmentId, containerId, options.onStderr);
} else {
await streamLocalStderr(containerId, options.envId, options.onStderr);
}
// Container has exited. Now fetch stdout reliably (no race condition).
const stdout = await fetchContainerStdout(containerId, config, options.envId);
return stdout;
} finally {
// Always cleanup container
try {
await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
} catch {
// Ignore cleanup errors
}
}
}
return stdout;
// Stream only stderr for real-time progress (local/standard mode)
async function streamLocalStderr(
containerId: string,
envId: number | null | undefined,
onStderr?: (data: string) => void
): Promise<void> {
const response = await dockerFetch(
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
{ streaming: true },
envId
);
const reader = response.body?.getReader();
if (!reader) return;
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer = Buffer.concat([buffer, Buffer.from(value)]);
const result = processStreamFrames(buffer, undefined, onStderr);
buffer = result.remaining;
}
}
// Stream only stderr for real-time progress (edge mode)
async function streamEdgeStderr(
environmentId: number,
containerId: string,
onStderr?: (data: string) => void
): Promise<void> {
return new Promise((resolve, reject) => {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
sendEdgeStreamRequest(
environmentId,
'GET',
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
{
onData: (data: string) => {
try {
const decoded = Buffer.from(data, 'base64');
buffer = Buffer.concat([buffer, decoded]);
const result = processStreamFrames(buffer, undefined, onStderr);
buffer = result.remaining;
} catch {
// Ignore decode errors
}
},
onEnd: () => resolve(),
onError: (error: string) => {
// Container exited = success
if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) {
resolve();
} else {
reject(new Error(error));
}
}
}
);
});
}
// Fetch stdout after container has exited (reliable, no race)
async function fetchContainerStdout(
containerId: string,
config: Awaited<ReturnType<typeof getDockerConfig>>,
envId: number | null | undefined
): Promise<string> {
if (config.connectionType === 'hawser-edge' && config.environmentId) {
const response = await sendEdgeRequest(
config.environmentId,
'GET',
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`
);
if (!response.body) return '';
const bodyData = typeof response.body === 'string'
? Buffer.from(response.body, 'base64')
: Buffer.from(response.body);
const result = processStreamFrames(bodyData, undefined, undefined);
return result.stdout;
}
// Local/standard mode
const response = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`,
{},
envId
);
const buffer = Buffer.from(await response.arrayBuffer());
const result = processStreamFrames(buffer, undefined, undefined);
return result.stdout;
}
// Push image to registry
+107 -96
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { join, resolve, dirname, basename, relative } from 'node:path';
import {
getGitRepository,
getGitCredential,
@@ -11,7 +11,7 @@ import {
type GitCredential,
type GitStackWithRepo
} from './db';
import { deployStack } from './stacks';
import { deployStack, getStackDir } from './stacks';
// Directory for storing cloned repositories
const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos';
@@ -142,8 +142,10 @@ export interface SyncResult {
commit?: string;
composeContent?: string;
composeDir?: string; // Directory containing the compose file (for copying all files)
composeFileName?: string; // Filename of the compose file (e.g., "docker-compose.yaml")
envFileVars?: Record<string, string>; // Variables from .env file in repo
envFileContent?: string; // Raw .env file content (for Hawser deployments)
envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env")
error?: string;
updated?: boolean;
}
@@ -505,6 +507,22 @@ function getStackRepoPath(stackId: number): string {
return join(GIT_REPOS_DIR, `stack-${stackId}`);
}
/**
* Get the current commit hash from a repo path (if it exists).
* Used to detect if repo was updated after re-clone.
*/
async function getPreviousCommit(repoPath: string, env: GitEnv): Promise<string | null> {
if (!existsSync(repoPath)) {
return null;
}
try {
const result = await execGit(['rev-parse', 'HEAD'], repoPath, env);
return result.code === 0 ? result.stdout.trim() : null;
} catch {
return null;
}
}
export async function syncGitStack(stackId: number): Promise<SyncResult> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
@@ -551,55 +569,40 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
console.log(`${logPrefix} Repo doesn't exist locally, cloning...`);
// Clone the repository (shallow clone)
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
console.log(`${logPrefix} Clone exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
console.log(`${logPrefix} Repo exists, pulling latest...`);
// Get current commit before pull
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
console.log(`${logPrefix} Commit before pull:`, beforeCommit.substring(0, 7));
// Pull latest changes
const result = await execGit(['pull', 'origin', repo.branch], repoPath, env);
console.log(`${logPrefix} Pull exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Pull stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Pull stderr:`, result.stderr);
if (result.code !== 0) {
throw new Error(`Git pull failed: ${result.stderr}`);
}
// Get commit after pull
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
console.log(`${logPrefix} Commit after pull:`, afterCommit.substring(0, 7));
updated = beforeCommit !== afterCommit;
console.log(`${logPrefix} Repo updated:`, updated);
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
const previousCommit = await getPreviousCommit(repoPath, env);
if (existsSync(repoPath)) {
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
rmSync(repoPath, { recursive: true, force: true });
}
console.log(`${logPrefix} Cloning repository...`);
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
console.log(`${logPrefix} Clone exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
updated = previousCommit !== newCommit;
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, updated: ${updated}`);
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
@@ -618,13 +621,16 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the compose directory (for copying all files)
// Determine the compose directory and filename (for copying all files)
const composeDir = dirname(composePath);
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
console.log(`${logPrefix} Compose directory:`, composeDir);
console.log(`${logPrefix} Compose filename:`, composeFileName);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
let envFileContent: string | undefined;
let envFileName: string | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
@@ -634,6 +640,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
envFileContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
// Compute env file path relative to compose directory
// This is needed for --env-file flag after files are copied to stack directory
envFileName = relative(composeDir, envFilePath);
console.log(`${logPrefix} Env filename relative to compose dir:`, envFileName);
} catch (err) {
// Log but don't fail - env file is optional
console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err);
@@ -668,7 +679,9 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
commit: currentCommit,
composeContent,
composeDir,
composeFileName,
envFileVars,
envFileName,
updated
};
} catch (error: any) {
@@ -735,12 +748,17 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// Note: Without this, docker compose only detects compose file changes, not env var changes
console.log(`${logPrefix} Calling deployStack...`);
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)');
const result = await deployStack({
name: gitStack.stackName,
compose: syncResult.composeContent!,
envId: gitStack.environmentId,
envFileVars: syncResult.envFileVars,
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate
});
@@ -752,13 +770,21 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) {
// Record the stack source
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = syncResult.composeFileName
? join(stackDir, syncResult.composeFileName)
: undefined;
console.log(`${logPrefix} Resolved compose path for stack_sources:`, resolvedComposePath);
await upsertStackSource({
stackName: gitStack.stackName,
environmentId: gitStack.environmentId,
sourceType: 'git',
gitRepositoryId: gitStack.repositoryId,
gitStackId: stackId
gitStackId: stackId,
composePath: resolvedComposePath
});
}
@@ -873,54 +899,39 @@ export async function deployGitStackWithProgress(
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
const previousCommit = await getPreviousCommit(repoPath, env);
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
// Step 3: Fetching
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
// Step 2-3: Fetching and resetting to latest (works with shallow clones)
onProgress({ status: 'fetching', message: 'Fetching latest changes...', step: 2, totalSteps });
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
// Fetch the latest from origin (shallow fetch)
const fetchResult = await execGit(['fetch', '--depth=1', 'origin', repo.branch], repoPath, env);
if (fetchResult.code !== 0) {
throw new Error(`Git fetch failed: ${fetchResult.stderr}`);
}
// Reset to the fetched commit (this works reliably with shallow clones)
onProgress({ status: 'fetching', message: 'Updating to latest...', step: 3, totalSteps });
const resetResult = await execGit(['reset', '--hard', `origin/${repo.branch}`], repoPath, env);
if (resetResult.code !== 0) {
throw new Error(`Git reset failed: ${resetResult.stderr}`);
}
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
updated = beforeCommit !== afterCommit;
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
const cloneResult = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
if (cloneResult.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${cloneResult.stderr}`);
}
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
updated = previousCommit !== newCommit;
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
+232
View File
@@ -0,0 +1,232 @@
/**
* Host Path Resolution Module
*
* Dockhand runs inside a Docker container where paths differ from the host.
* This module detects the host path for the DATA_DIR mount, enabling proper
* volume path resolution for compose stacks.
*
* Problem:
* - Dockhand container has /app/data mounted from host (e.g., -v dockhand_data:/app/data)
* - Compose file says: ./ca.pem:/ca.pem (relative path)
* - docker-compose resolves this to /app/data/stacks/.../ca.pem
* - Docker daemon on HOST receives this path, but /app/data doesn't exist on host!
* - Docker creates a directory instead of mounting the file
*
* Solution:
* - Query Docker API to find the host source path for our /app/data mount
* - Rewrite relative paths in compose files to use the host path
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Cache the host data dir to avoid repeated API calls
let cachedHostDataDir: string | null = null;
let detectionAttempted = false;
/**
* Get our own container ID
*/
function getOwnContainerId(): string | null {
// Method 1: From cgroup (works in most cases)
try {
const cgroup = readFileSync('/proc/self/cgroup', 'utf-8');
// Look for docker container ID (64 hex chars)
const match = cgroup.match(/[a-f0-9]{64}/);
if (match) {
return match[0];
}
} catch {
// Can't read cgroup
}
// Method 2: From mountinfo
try {
const mountinfo = readFileSync('/proc/self/mountinfo', 'utf-8');
const match = mountinfo.match(/\/docker\/containers\/([a-f0-9]{64})/);
if (match) {
return match[1];
}
} catch {
// Can't read mountinfo
}
// Method 3: HOSTNAME might be container ID (short form)
const hostname = process.env.HOSTNAME;
if (hostname && /^[a-f0-9]{12}$/.test(hostname)) {
return hostname;
}
return null;
}
/**
* Get the host path for our DATA_DIR mount by inspecting our own container
*/
export async function detectHostDataDir(): Promise<string | null> {
// Return cached value if already detected
if (detectionAttempted) {
return cachedHostDataDir;
}
detectionAttempted = true;
// Check if user explicitly set HOST_DATA_DIR
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID');
return null;
}
console.log(`[HostPath] Detected container ID: ${containerId.substring(0, 12)}`);
// Get DATA_DIR (inside container)
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
try {
// Query Docker API to inspect our own container
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
// Use fetch with unix socket
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
// @ts-ignore - Bun supports unix sockets
unix: socketPath
});
if (!response.ok) {
console.warn(`[HostPath] Failed to inspect container: ${response.status}`);
return null;
}
const containerInfo = await response.json() as {
Mounts?: Array<{
Type: string;
Source: string;
Destination: string;
}>;
};
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
if (dataMount) {
cachedHostDataDir = dataMount.Source;
console.log(`[HostPath] Detected host path for ${dataDir}: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
// Check if DATA_DIR is a subdirectory of a mount
for (const mount of containerInfo.Mounts || []) {
if (dataDir.startsWith(mount.Destination + '/') || dataDir === mount.Destination) {
const relativePath = dataDir.substring(mount.Destination.length);
cachedHostDataDir = mount.Source + relativePath;
console.log(`[HostPath] Detected host path for ${dataDir} via parent mount: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
}
console.warn(`[HostPath] Could not find mount for ${dataDir} in container mounts`);
return null;
} catch (err) {
console.warn(`[HostPath] Failed to query Docker API: ${err}`);
return null;
}
}
/**
* Get the cached host data dir (call detectHostDataDir first during startup)
*/
export function getHostDataDir(): string | null {
return cachedHostDataDir;
}
/**
* Translate a container path to host path
*
* @param containerPath - Path inside the container (e.g., /app/data/stacks/mystack/file.txt)
* @returns Host path if translation is needed, or original path if not
*/
export function translateToHostPath(containerPath: string): string {
const hostDataDir = getHostDataDir();
if (!hostDataDir) {
return containerPath;
}
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
// Check if the path is under DATA_DIR
if (containerPath.startsWith(dataDir + '/') || containerPath === dataDir) {
const relativePath = containerPath.substring(dataDir.length);
return hostDataDir + relativePath;
}
return containerPath;
}
/**
* Rewrite relative volume paths in a compose file to use absolute host paths.
* This is necessary when Dockhand runs inside Docker with a mounted data volume.
*
* Transforms:
* ./config.toml:/config.toml -> /host/path/to/stack/config.toml:/config.toml
*
* @param composeContent - The compose file content
* @param workingDir - The working directory (container path) where the compose file is located
* @returns Modified compose content with absolute host paths, or original if no translation needed
*/
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
const hostDataDir = getHostDataDir();
const changes: string[] = [];
if (!hostDataDir) {
return { content: composeContent, modified: false, changes };
}
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
// Check if workingDir is under DATA_DIR
if (!workingDir.startsWith(dataDir + '/') && workingDir !== dataDir) {
return { content: composeContent, modified: false, changes };
}
// Calculate the host working directory
const relativePath = workingDir.substring(dataDir.length);
const hostWorkingDir = hostDataDir + relativePath;
// Parse compose content line by line to find and rewrite volume mounts
// We look for patterns like:
// - ./something:/container/path
// - "./something:/container/path"
// - './something:/container/path'
const lines = composeContent.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
// Match volume mount patterns with relative paths
// Handles: - ./path:/dest, - "./path:/dest", - './path:/dest'
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/);
if (volumeMatch) {
const [, prefix, quote, relativeSrc, , destPart] = volumeMatch;
// Convert relative path to absolute host path
const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
modifiedLines.push(line);
}
}
return {
content: modifiedLines.join('\n'),
modified: changes.length > 0,
changes
};
}
+14
View File
@@ -497,6 +497,12 @@ export async function scanWithGrype(
}
);
// Defensive logging for empty output
console.log(`[Grype] Scanner container output received, length: ${output.length}`);
if (output.length === 0) {
console.error('[Grype] WARNING: Empty output from scanner container - possible race condition');
}
onProgress?.({
stage: 'parsing',
message: 'Parsing scan results...',
@@ -589,6 +595,12 @@ export async function scanWithTrivy(
}
);
// Defensive logging for empty output
console.log(`[Trivy] Scanner container output received, length: ${output.length}`);
if (output.length === 0) {
console.error('[Trivy] WARNING: Empty output from scanner container - possible race condition');
}
onProgress?.({
stage: 'parsing',
message: 'Parsing scan results...',
@@ -731,6 +743,7 @@ async function getScannerVersion(
// Create temporary container to get version
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
const { stdout, stderr } = await runContainer({
image: scannerImage,
cmd: versionCmd,
@@ -738,6 +751,7 @@ async function getScannerVersion(
envId
});
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
const output = stdout || stderr;
// Parse version from output
@@ -31,7 +31,7 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
/**
* Execute a container auto-update.
@@ -98,14 +98,18 @@ export async function runContainerUpdate(
return;
}
// Prevent Dockhand from updating itself
if (isDockhandContainer(imageNameFromConfig)) {
log(`Skipping Dockhand container - cannot auto-update self`);
// Prevent system containers (Dockhand/Hawser) from being updated
const systemContainerType = isSystemContainer(imageNameFromConfig);
if (systemContainerType) {
const reason = systemContainerType === 'dockhand'
? 'Cannot auto-update Dockhand itself'
: 'Cannot auto-update Hawser agent';
log(`Skipping ${systemContainerType} container - ${reason}`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Cannot auto-update Dockhand itself' }
details: { reason }
});
return;
}
@@ -33,7 +33,7 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
interface UpdateInfo {
containerId: string;
@@ -224,9 +224,13 @@ export async function runEnvUpdateCheckJob(
const blockedContainers: { name: string; reason: string; scannerResults?: { scanner: string; critical: number; high: number; medium: number; low: number }[] }[] = [];
for (const update of updatesAvailable) {
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(update.imageName)) {
await log(`\n[${update.containerName}] Skipping - cannot auto-update Dockhand itself`);
// Skip system containers (Dockhand/Hawser) - cannot update themselves
const systemContainerType = isSystemContainer(update.imageName);
if (systemContainerType) {
const reason = systemContainerType === 'dockhand'
? 'cannot auto-update Dockhand itself'
: 'cannot auto-update Hawser agent';
await log(`\n[${update.containerName}] Skipping - ${reason}`);
continue;
}
@@ -99,6 +99,32 @@ export function isDockhandContainer(imageName: string): boolean {
return imageName.toLowerCase().includes('fnsys/dockhand');
}
/**
* Check if a container is a Hawser agent.
* Official image: ghcr.io/finsys/hawser
*/
export function isHawserContainer(imageName: string): boolean {
const lower = imageName.toLowerCase();
return lower.includes('finsys/hawser') || lower.includes('ghcr.io/finsys/hawser');
}
/**
* System container type - containers that cannot be updated from within Dockhand.
*/
export type SystemContainerType = 'dockhand' | 'hawser';
/**
* Check if a container is a system container (Dockhand or Hawser).
* System containers cannot be updated from within Dockhand because:
* - Dockhand: Would need to stop itself to update
* - Hawser: Would disconnect from the environment it's managing
*/
export function isSystemContainer(imageName: string): SystemContainerType | null {
if (isDockhandContainer(imageName)) return 'dockhand';
if (isHawserContainer(imageName)) return 'hawser';
return null;
}
/**
* Combine multiple scan summaries by taking the maximum of each severity level.
*/
+2 -2
View File
@@ -9,8 +9,8 @@ import { readdirSync, existsSync, statSync } from 'node:fs';
import { join, basename, dirname, resolve } from 'node:path';
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
// Compose file patterns to detect (in order of priority)
const COMPOSE_PATTERNS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
// Compose file patterns to detect (in order of priority - prefer new style first)
const COMPOSE_PATTERNS = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
// Directories to skip during scanning
const SKIP_DIRECTORIES = ['.git', 'node_modules', '.docker', '__pycache__', '.venv', 'venv'];
+234 -44
View File
@@ -23,11 +23,23 @@ import {
deleteStackEnvVars
} from './db';
import { deleteGitStackFiles } from './git';
import { cleanPem } from '$lib/utils/pem';
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
// =============================================================================
// TYPES
// =============================================================================
/**
* TLS configuration for remote Docker connections
*/
interface TlsConfig {
ca?: string;
cert?: string;
key?: string;
skipVerify?: boolean;
}
/**
* Stack source types
*/
@@ -82,6 +94,10 @@ export interface DeployStackOptions {
envFileVars?: Record<string, string>;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
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
}
// =============================================================================
@@ -114,6 +130,24 @@ let _stacksDir: string | null = null;
// Per-stack locking mechanism to prevent race conditions during concurrent operations
const stackLocks = new Map<string, Promise<void>>();
// Track active TLS temp directories for cleanup on unexpected process exit
const activeTlsDirs = new Set<string>();
// 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.
@@ -287,9 +321,9 @@ export function listManagedStacks(): string[] {
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);
// Check all valid compose filenames
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
return composeNames.some(name => existsSync(join(stacksDir, dirent.name, name)));
})
.map((dirent) => dirent.name);
}
@@ -381,8 +415,8 @@ export async function getStackComposeFile(
const stackDir = await findStackDir(stackName, envId);
if (stackDir) {
// Check all common compose file names
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
// 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);
@@ -617,7 +651,7 @@ export async function saveStackComposeFile(
stackDir = existingDir;
}
const composeFile = join(stackDir, 'docker-compose.yml');
const composeFile = join(stackDir, 'compose.yaml');
const exists = existsSync(stackDir);
if (create) {
@@ -720,11 +754,14 @@ interface ComposeCommandOptions {
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;
}
/**
* Execute a docker compose command locally via Bun.spawn.
*
* @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)
@@ -735,13 +772,15 @@ async function executeLocalCompose(
stackName: string,
composeContent: string,
dockerHost?: string,
tlsConfig?: TlsConfig,
envVars?: Record<string, string>,
secretVars?: Record<string, string>,
forceRecreate?: boolean,
removeVolumes?: boolean,
envId?: number | null,
workingDir?: string,
customComposePath?: string
customComposePath?: string,
customEnvPath?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -752,21 +791,45 @@ async function executeLocalCompose(
let composeFile: string;
if (customComposePath && workingDir) {
// Imported stack: use original location, don't copy files
// 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;
// Don't write to the compose file - it already exists at the custom location
// The user manages this file externally
} 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, 'docker-compose.yml');
composeFile = join(stackDir, 'compose.yaml');
await Bun.write(composeFile, composeContent);
}
// 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
let finalComposeContent = composeContent;
if (!dockerHost && getHostDataDir()) {
const rewriteResult = rewriteComposeVolumePaths(composeContent, stackDir);
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] ----------------------------------------`);
}
}
// Build spawn environment:
// 1. Start with process.env
// 2. Add DOCKER_HOST if specified
@@ -785,8 +848,58 @@ async function executeLocalCompose(
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) await Bun.write(join(tlsCertDir, 'ca.pem'), cleanedCa);
}
if (tlsConfig.cert) {
const cleanedCert = cleanPem(tlsConfig.cert);
if (cleanedCert) await Bun.write(join(tlsCertDir, 'cert.pem'), cleanedCert);
}
if (tlsConfig.key) {
const cleanedKey = cleanPem(tlsConfig.key);
if (cleanedKey) await Bun.write(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
const args = ['docker', 'compose', '-p', stackName, '-f', composeFile];
// If we have modified compose content (host path translation), use stdin instead of file
const useStdin = finalComposeContent !== composeContent;
const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile];
// Add --env-file flag if env file exists
// This makes Docker Compose load the .env file automatically (like Portainer)
// Uses custom path if provided, otherwise defaults to .env in stack directory
const envFilePath = customEnvPath || join(stackDir, '.env');
if (existsSync(envFilePath)) {
args.push('--env-file', envFilePath);
}
if (useStdin) {
console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`);
}
switch (operation) {
case 'up':
@@ -836,10 +949,17 @@ async function executeLocalCompose(
const proc = Bun.spawn(args, {
cwd: stackDir,
env: spawnEnv,
stdin: useStdin ? 'pipe' : 'inherit',
stdout: 'pipe',
stderr: '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(() => {
@@ -909,6 +1029,17 @@ async function executeLocalCompose(
output: '',
error: `Failed to run docker compose ${operation}: ${err.message}`
};
} finally {
// 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
}
}
}
}
@@ -1063,7 +1194,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath } = options;
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1074,14 +1205,16 @@ async function executeComposeCommand(
operation,
stackName,
composeContent,
undefined,
undefined, // dockerHost
undefined, // tlsConfig
envVars,
secretVars,
forceRecreate,
removeVolumes,
envId,
workingDir,
composePath
composePath,
envPath
);
}
@@ -1103,18 +1236,29 @@ async function executeComposeCommand(
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;
return executeLocalCompose(
operation,
stackName,
composeContent,
dockerHost,
tlsConfig,
envVars,
secretVars,
forceRecreate,
removeVolumes,
envId,
workingDir,
composePath
composePath,
envPath
);
}
@@ -1124,14 +1268,16 @@ async function executeComposeCommand(
operation,
stackName,
composeContent,
undefined,
undefined, // dockerHost
undefined, // tlsConfig
envVars,
secretVars,
forceRecreate,
removeVolumes,
envId,
workingDir,
composePath
composePath,
envPath
);
}
}
@@ -1437,7 +1583,8 @@ async function requireComposeFile(
envVars,
secretVars,
stackDir: composeResult.stackDir,
composePath: composeResult.composePath
composePath: composeResult.composePath ?? undefined,
envPath: envFilePath ?? undefined
};
}
@@ -1458,7 +1605,7 @@ export async function startStack(
return executeComposeCommand(
'up',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1482,7 +1629,7 @@ export async function stopStack(
return executeComposeCommand(
'stop',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1506,7 +1653,7 @@ export async function restartStack(
return executeComposeCommand(
'restart',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1531,7 +1678,7 @@ export async function downStack(
return executeComposeCommand(
'down',
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1561,7 +1708,8 @@ export async function removeStack(
stackName,
envId,
workingDir: composeResult.stackDir,
composePath: composeResult.composePath
composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined
},
composeResult.content!,
envVars,
@@ -1671,7 +1819,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, envFileVars, sourceDir, forceRecreate } = options;
const { name, compose, envId, envFileVars, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -1680,6 +1828,10 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
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)');
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(', '));
@@ -1698,31 +1850,56 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
}
return withStackLock(name, async () => {
const stackDir = await getStackDir(name, envId);
// Read all files from source directory if provided (for Hawser deployments)
// 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<string, string> | undefined;
if (sourceDir && existsSync(sourceDir)) {
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);
console.log(`${logPrefix} Actual compose path will be:`, actualComposePath);
}
// 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(', '));
}
// Handle stack directory setup
if (sourceDir && existsSync(sourceDir)) {
// Copy entire source directory to stack directory (for git stacks)
// Copy source to stack directory
console.log(`${logPrefix} Copying source directory to stack directory...`);
if (existsSync(stackDir)) {
rmSync(stackDir, { recursive: true, force: true });
if (existsSync(workingDir)) {
rmSync(workingDir, { recursive: true, force: true });
}
cpSync(sourceDir, stackDir, { recursive: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${stackDir}`);
cpSync(sourceDir, workingDir, { recursive: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Traditional behavior: create directory and write compose file only
mkdirSync(stackDir, { recursive: true });
const composeFile = join(stackDir, 'docker-compose.yml');
await Bun.write(composeFile, compose);
console.log(`${logPrefix} Compose file written to:`, composeFile);
// Internal stack: compose file should already exist (written by saveStackComposeFile)
// Just determine the working directory
workingDir = await getStackDir(name, envId);
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
}
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
@@ -1746,7 +1923,20 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
}
console.log(`${logPrefix} Calling executeComposeCommand...`);
const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate, stackFiles }, compose, envVars);
const result = await executeComposeCommand(
'up',
{
stackName: name,
envId,
forceRecreate,
stackFiles,
workingDir,
composePath: actualComposePath,
envPath: actualEnvPath
},
compose,
envVars
);
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} DEPLOY STACK RESULT`);
console.log(`${logPrefix} ========================================`);
@@ -1779,7 +1969,7 @@ export async function pullStackImages(
return executeComposeCommand(
'pull',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -544,8 +544,9 @@ async function refreshEventCollectors() {
}
}
} catch (error) {
console.error('[EventSubprocess] Failed to refresh collectors:', error);
send({ type: 'error', message: `Failed to refresh collectors: ${error}` });
const message = error instanceof Error ? error.message : String(error);
console.error(`[EventSubprocess] Failed to refresh collectors: ${message}`);
send({ type: 'error', message: `Failed to refresh collectors: ${message}` });
}
}
@@ -614,7 +615,8 @@ async function start(): Promise<void> {
currentPollInterval = await getEventPollInterval();
console.log(`[EventSubprocess] Initial mode: ${currentMode}, poll interval: ${currentPollInterval}ms`);
} catch (error) {
console.error('[EventSubprocess] Failed to load settings, using defaults:', error);
const message = error instanceof Error ? error.message : String(error);
console.error(`[EventSubprocess] Failed to load settings, using defaults: ${message}`);
}
// Start collectors for all environments
@@ -132,7 +132,8 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string;
}
} catch (error) {
// Skip this environment if it fails (might be offline)
console.error(`[MetricsSubprocess] Failed to collect metrics for ${env.name}:`, error);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[MetricsSubprocess] Failed to collect metrics for ${env.name}: ${message}`);
}
}
@@ -165,12 +166,14 @@ async function collectMetrics() {
if (result.status === 'fulfilled' && result.value === null) {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics timed out after ${ENV_METRICS_TIMEOUT}ms`);
} else if (result.status === 'rejected') {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed:`, result.reason);
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`);
}
});
} catch (error) {
console.error('[MetricsSubprocess] Metrics collection error:', error);
send({ type: 'error', message: `Metrics collection error: ${error}` });
const message = error instanceof Error ? error.message : String(error);
console.error(`[MetricsSubprocess] Metrics collection error: ${message}`);
send({ type: 'error', message: `Metrics collection error: ${message}` });
}
}
@@ -308,7 +311,8 @@ async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics
}
} catch (error) {
// Skip this environment if it fails
console.error(`[MetricsSubprocess] Failed to check disk space for ${env.name}:`, error);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[MetricsSubprocess] Failed to check disk space for ${env.name}: ${message}`);
}
}
@@ -341,12 +345,14 @@ async function checkDiskSpace() {
if (result.status === 'fulfilled' && result.value === null) {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check timed out after ${ENV_DISK_TIMEOUT}ms`);
} else if (result.status === 'rejected') {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed:`, result.reason);
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed: ${reason}`);
}
});
} catch (error) {
console.error('[MetricsSubprocess] Disk space check error:', error);
send({ type: 'error', message: `Disk space check error: ${error}` });
const message = error instanceof Error ? error.message : String(error);
console.error(`[MetricsSubprocess] Disk space check error: ${message}`);
send({ type: 'error', message: `Disk space check error: ${message}` });
}
}