1.0.9
This commit is contained in:
1 parent
62636426bf
commit
c185d00dc3
60 files changed
+2830
-918
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user