1.0.7
This commit is contained in:
1 parent
bd7b832394
commit
7643807717
86 files changed
+3644
-1385
No files matched your search
+46
-10
@@ -9,8 +9,9 @@
|
||||
* - SameSite=Strict (CSRF protection)
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { secureRandomBytes, usingFallback } from './crypto-fallback';
|
||||
import { argon2id, argon2Verify } from 'hash-wasm';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import {
|
||||
getAuthSettings,
|
||||
@@ -94,27 +95,62 @@ export interface LoginResult {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Password Hashing (Argon2id via Bun.password)
|
||||
// Password Hashing (Argon2id)
|
||||
// ============================================
|
||||
|
||||
// Argon2id parameters (matching Bun.password defaults)
|
||||
const ARGON2_MEMORY_COST = 65536; // 64 MB in kibibytes
|
||||
const ARGON2_TIME_COST = 3; // 3 iterations
|
||||
const ARGON2_PARALLELISM = 1; // Single-threaded
|
||||
const ARGON2_HASH_LENGTH = 32; // 256-bit output
|
||||
const ARGON2_SALT_LENGTH = 16; // 128-bit salt
|
||||
|
||||
/**
|
||||
* Hash a password using Argon2id via Bun's native password API
|
||||
* Hash a password using Argon2id
|
||||
*
|
||||
* On modern kernels (>=3.17): Uses Bun's native password API (faster)
|
||||
* On old kernels (<3.17): Uses hash-wasm (WASM-based, no getrandom dependency)
|
||||
*
|
||||
* Argon2id is the recommended variant - resistant to both side-channel and GPU attacks
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// On old kernels, Bun.password.hash() crashes because it internally uses getrandom()
|
||||
// Use hash-wasm as a fallback which is pure WASM and doesn't depend on the syscall
|
||||
if (usingFallback()) {
|
||||
const salt = secureRandomBytes(ARGON2_SALT_LENGTH);
|
||||
return argon2id({
|
||||
password,
|
||||
salt,
|
||||
iterations: ARGON2_TIME_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
memorySize: ARGON2_MEMORY_COST,
|
||||
hashLength: ARGON2_HASH_LENGTH,
|
||||
outputType: 'encoded' // Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
|
||||
});
|
||||
}
|
||||
|
||||
// Modern kernels: use Bun's native implementation (faster)
|
||||
return Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3 // 3 iterations
|
||||
memoryCost: ARGON2_MEMORY_COST,
|
||||
timeCost: ARGON2_TIME_COST
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* Uses constant-time comparison internally
|
||||
*
|
||||
* Both Bun.password and hash-wasm use the same PHC format, so hashes are compatible
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
// On old kernels, use hash-wasm for verification
|
||||
if (usingFallback()) {
|
||||
return await argon2Verify({ password, hash });
|
||||
}
|
||||
|
||||
// Modern kernels: use Bun's native implementation
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -130,7 +166,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
* 32 bytes = 256 bits of entropy
|
||||
*/
|
||||
function generateSessionToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
return secureRandomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,7 +447,7 @@ export async function authenticateLocal(
|
||||
|
||||
if (!user) {
|
||||
// Use constant time to prevent timing attacks
|
||||
await Bun.password.hash('dummy', { algorithm: 'argon2id' });
|
||||
await hashPassword('dummy');
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
@@ -1127,7 +1163,7 @@ async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocumen
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
|
||||
const codeVerifier = randomBytes(32).toString('base64url');
|
||||
const codeVerifier = secureRandomBytes(32).toString('base64url');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(codeVerifier);
|
||||
const codeChallenge = hasher.digest('base64url') as string;
|
||||
@@ -1150,8 +1186,8 @@ export async function buildOidcAuthorizationUrl(
|
||||
const discovery = await getOidcDiscovery(config.issuerUrl);
|
||||
|
||||
// Generate state, nonce, and PKCE
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const nonce = randomBytes(16).toString('base64url');
|
||||
const state = secureRandomBytes(32).toString('base64url');
|
||||
const nonce = secureRandomBytes(16).toString('base64url');
|
||||
const { codeVerifier, codeChallenge } = generatePkce();
|
||||
|
||||
// Store state for callback verification (expires in 10 minutes)
|
||||
|
||||
+166
-8
@@ -125,6 +125,11 @@ export async function getEnvironment(id: number): Promise<Environment | undefine
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function getEnvironmentByName(name: string): Promise<Environment | undefined> {
|
||||
const results = await db.select().from(environments).where(eq(environments.name, name));
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
|
||||
const result = await db.insert(environments).values({
|
||||
name: env.name,
|
||||
@@ -2487,6 +2492,8 @@ export interface StackSourceData {
|
||||
sourceType: StackSourceType;
|
||||
gitRepositoryId: number | null;
|
||||
gitStackId: number | null;
|
||||
composePath: string | null;
|
||||
envPath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2527,9 +2534,10 @@ export async function getStackSource(stackName: string, environmentId?: number |
|
||||
|
||||
export async function getStackSources(environmentId?: number | null): Promise<StackSourceWithRepo[]> {
|
||||
let results;
|
||||
if (environmentId !== undefined) {
|
||||
if (environmentId !== undefined && environmentId !== null) {
|
||||
// Only get stacks for the specific environment
|
||||
results = await db.select().from(stackSources)
|
||||
.where(or(eq(stackSources.environmentId, environmentId), isNull(stackSources.environmentId)))
|
||||
.where(eq(stackSources.environmentId, environmentId))
|
||||
.orderBy(asc(stackSources.stackName));
|
||||
} else {
|
||||
results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName));
|
||||
@@ -2563,6 +2571,8 @@ export async function upsertStackSource(data: {
|
||||
sourceType: StackSourceType;
|
||||
gitRepositoryId?: number | null;
|
||||
gitStackId?: number | null;
|
||||
composePath?: string | null;
|
||||
envPath?: string | null;
|
||||
}): Promise<StackSourceData> {
|
||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||
|
||||
@@ -2572,6 +2582,8 @@ export async function upsertStackSource(data: {
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
@@ -2582,12 +2594,33 @@ export async function upsertStackSource(data: {
|
||||
environmentId: data.environmentId ?? null,
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null
|
||||
gitStackId: data.gitStackId || null,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null
|
||||
});
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStackSource(
|
||||
stackName: string,
|
||||
environmentId: number | null,
|
||||
updates: { composePath?: string | null; envPath?: string | null }
|
||||
): Promise<boolean> {
|
||||
const existing = await getStackSource(stackName, environmentId);
|
||||
if (!existing) return false;
|
||||
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
|
||||
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
await db.delete(stackSources)
|
||||
@@ -3083,10 +3116,8 @@ export interface ContainerEventResult {
|
||||
}
|
||||
|
||||
export async function logContainerEvent(data: ContainerEventCreateData): Promise<ContainerEventData> {
|
||||
// Timestamp is always a string with nanosecond precision (stored as text in both SQLite and PostgreSQL)
|
||||
// For PostgreSQL, we convert to Date since the schema uses native timestamp type
|
||||
const timestamp = isPostgres ? new Date(data.timestamp) : data.timestamp;
|
||||
|
||||
// Timestamp is already an ISO-8601 string from event-subprocess
|
||||
// Both SQLite and PostgreSQL schemas use mode: 'string' so we pass it directly
|
||||
const result = await db.insert(containerEvents).values({
|
||||
environmentId: data.environmentId ?? null,
|
||||
containerId: data.containerId,
|
||||
@@ -3094,7 +3125,7 @@ export async function logContainerEvent(data: ContainerEventCreateData): Promise
|
||||
image: data.image ?? null,
|
||||
action: data.action,
|
||||
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
|
||||
timestamp
|
||||
timestamp: data.timestamp
|
||||
}).returning();
|
||||
|
||||
return getContainerEvent(result[0].id) as Promise<ContainerEventData>;
|
||||
@@ -3896,6 +3927,73 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXTERNAL STACK PATHS
|
||||
// =============================================================================
|
||||
|
||||
const EXTERNAL_STACK_PATHS_KEY = 'external_stack_paths';
|
||||
|
||||
export async function getExternalStackPaths(): Promise<string[]> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
|
||||
if (result[0]) {
|
||||
try {
|
||||
const parsed = JSON.parse(result[0].value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function setExternalStackPaths(paths: string[]): Promise<void> {
|
||||
const jsonValue = JSON.stringify(paths);
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: jsonValue, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: EXTERNAL_STACK_PATHS_KEY,
|
||||
value: jsonValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRIMARY STACK LOCATION
|
||||
// =============================================================================
|
||||
|
||||
const PRIMARY_STACK_LOCATION_KEY = 'primary_stack_location';
|
||||
|
||||
export async function getPrimaryStackLocation(): Promise<string | null> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
if (result[0]?.value) {
|
||||
return result[0].value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setPrimaryStackLocation(path: string | null): Promise<void> {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
if (path === null) {
|
||||
// Delete the setting if path is null
|
||||
if (existing.length > 0) {
|
||||
await db.delete(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
}
|
||||
} else if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: path, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: PRIMARY_STACK_LOCATION_KEY,
|
||||
value: path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENVIRONMENT UPDATE CHECK SETTINGS
|
||||
// =============================================================================
|
||||
@@ -3988,6 +4086,66 @@ export async function setDefaultTimezone(timezone: string): Promise<void> {
|
||||
await setSetting('default_timezone', timezone);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACKGROUND MONITORING SETTINGS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get event collection mode ('stream' or 'poll').
|
||||
* Defaults to 'stream' for real-time event streaming.
|
||||
*/
|
||||
export async function getEventCollectionMode(): Promise<'stream' | 'poll'> {
|
||||
const value = await getSetting('event_collection_mode');
|
||||
return value || 'stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event collection mode.
|
||||
*/
|
||||
export async function setEventCollectionMode(mode: 'stream' | 'poll'): Promise<void> {
|
||||
await setSetting('event_collection_mode', mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event poll interval in milliseconds.
|
||||
* Defaults to 60000ms (60 seconds).
|
||||
*/
|
||||
export async function getEventPollInterval(): Promise<number> {
|
||||
const value = await getSetting('event_poll_interval');
|
||||
return value || 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event poll interval in milliseconds.
|
||||
* Valid range: 30000ms (30s) to 300000ms (5min).
|
||||
*/
|
||||
export async function setEventPollInterval(interval: number): Promise<void> {
|
||||
if (interval < 30000 || interval > 300000) {
|
||||
throw new Error('Event poll interval must be between 30s and 300s');
|
||||
}
|
||||
await setSetting('event_poll_interval', interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics collection interval in milliseconds.
|
||||
* Defaults to 30000ms (30 seconds) - changed from hardcoded 10s.
|
||||
*/
|
||||
export async function getMetricsCollectionInterval(): Promise<number> {
|
||||
const value = await getSetting('metrics_collection_interval');
|
||||
return value || 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metrics collection interval in milliseconds.
|
||||
* Valid range: 10000ms (10s) to 300000ms (5min).
|
||||
*/
|
||||
export async function setMetricsCollectionInterval(interval: number): Promise<void> {
|
||||
if (interval < 10000 || interval > 300000) {
|
||||
throw new Error('Metrics collection interval must be between 10s and 300s');
|
||||
}
|
||||
await setSetting('metrics_collection_interval', interval);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STACK ENVIRONMENT VARIABLES OPERATIONS
|
||||
// =============================================================================
|
||||
|
||||
@@ -604,21 +604,21 @@ async function initializeDatabase() {
|
||||
logHeader('DATABASE INITIALIZATION');
|
||||
|
||||
if (isPostgres) {
|
||||
// PostgreSQL via Bun.sql
|
||||
// PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
|
||||
validatePostgresUrl(config.databaseUrl!);
|
||||
|
||||
logInfo(`Database: PostgreSQL`);
|
||||
logInfo(`Connection: ${maskPassword(config.databaseUrl!)}`);
|
||||
|
||||
const { drizzle } = await import('drizzle-orm/bun-sql');
|
||||
const { SQL } = await import('bun');
|
||||
const { drizzle } = await import('drizzle-orm/postgres-js');
|
||||
const postgres = (await import('postgres')).default;
|
||||
|
||||
// Import PostgreSQL schema
|
||||
schema = await import('./schema/pg-schema.js');
|
||||
|
||||
if (verbose) logStep('Connecting to PostgreSQL...');
|
||||
try {
|
||||
rawClient = new SQL(config.databaseUrl!);
|
||||
rawClient = postgres(config.databaseUrl!);
|
||||
db = drizzle({ client: rawClient, schema });
|
||||
logSuccess('PostgreSQL connection established');
|
||||
} catch (error) {
|
||||
|
||||
@@ -332,6 +332,8 @@ export const stackSources = sqliteTable('stack_sources', {
|
||||
sourceType: text('source_type').notNull().default('internal'),
|
||||
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
|
||||
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -335,6 +335,8 @@ export const stackSources = pgTable('stack_sources', {
|
||||
sourceType: text('source_type').notNull().default('internal'),
|
||||
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
|
||||
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
+98
-23
@@ -469,13 +469,16 @@ export async function dockerFetch(
|
||||
streaming ? 300000 : 30000 // 5 min for streaming, 30s for normal requests
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 5000) {
|
||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||
if (elapsed > 5000 && !path.includes('/stats')) {
|
||||
console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`);
|
||||
}
|
||||
return edgeResponseToResponse(edgeResponse);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms:`, error);
|
||||
// Log error message only, not full stack trace
|
||||
const msg = error?.message || String(error);
|
||||
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
||||
throw DockerConnectionError.fromError(error);
|
||||
}
|
||||
}
|
||||
@@ -491,13 +494,16 @@ export async function dockerFetch(
|
||||
...bunOptions
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 5000) {
|
||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||
if (elapsed > 5000 && !path.includes('/stats')) {
|
||||
console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms:`, error);
|
||||
// Log error message only, not full stack trace
|
||||
const msg = error?.message || String(error);
|
||||
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
||||
throw DockerConnectionError.fromError(error);
|
||||
}
|
||||
} else {
|
||||
@@ -516,21 +522,30 @@ export async function dockerFetch(
|
||||
}
|
||||
|
||||
// For HTTPS with TLS certificates, we need to configure TLS
|
||||
// IMPORTANT: Bun requires certificates as Buffer objects, not strings
|
||||
// Pass certificate strings directly to Bun's fetch - no temp files needed
|
||||
if (config.type === 'https') {
|
||||
const tlsOptions: Record<string, unknown> = {};
|
||||
|
||||
// CA certificate - must be array of Buffers for Bun
|
||||
// DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts,
|
||||
// which causes client certificate mismatches in mTLS scenarios. By setting
|
||||
// sessionTimeout to 0, we force a fresh TLS handshake for every connection.
|
||||
tlsOptions.sessionTimeout = 0;
|
||||
|
||||
// Set explicit servername for SNI - helps isolate TLS contexts per host
|
||||
tlsOptions.servername = config.host;
|
||||
|
||||
// Load CA certificate (just this environment's CA, not composite)
|
||||
// The sessionTimeout=0 should prevent session reuse across hosts
|
||||
if (config.ca) {
|
||||
tlsOptions.ca = [Buffer.from(config.ca)];
|
||||
tlsOptions.ca = [config.ca];
|
||||
}
|
||||
|
||||
// Client certificate and key for mTLS - must be Buffers
|
||||
// Client cert and key for mTLS authentication
|
||||
if (config.cert) {
|
||||
tlsOptions.cert = Buffer.from(config.cert);
|
||||
tlsOptions.cert = [config.cert];
|
||||
}
|
||||
if (config.key) {
|
||||
tlsOptions.key = Buffer.from(config.key);
|
||||
tlsOptions.key = config.key;
|
||||
}
|
||||
|
||||
// Skip verification (self-signed without CA)
|
||||
@@ -541,8 +556,27 @@ export async function dockerFetch(
|
||||
}
|
||||
|
||||
if (Object.keys(tlsOptions).length > 0) {
|
||||
// @ts-ignore - Bun supports tls options with Buffer certs
|
||||
// @ts-ignore - Bun supports tls options with string certs
|
||||
finalOptions.tls = tlsOptions;
|
||||
// Force new connection for each request to prevent Bun from reusing
|
||||
// a TLS session with wrong client certificates (pool key doesn't include certs)
|
||||
// @ts-ignore - Bun supports keepalive option
|
||||
finalOptions.keepalive = false;
|
||||
}
|
||||
|
||||
// Explicitly close connection to prevent TLS session reuse issues
|
||||
// But only for non-streaming requests (logs, events, exec need keep-alive)
|
||||
if (!streaming) {
|
||||
finalOptions.headers = {
|
||||
...finalOptions.headers,
|
||||
'Connection': 'close'
|
||||
};
|
||||
}
|
||||
|
||||
// Optional verbose TLS debugging
|
||||
if (process.env.DEBUG_TLS) {
|
||||
// @ts-ignore - Bun-specific verbose option
|
||||
finalOptions.verbose = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,13 +584,16 @@ export async function dockerFetch(
|
||||
try {
|
||||
const response = await fetch(url, { ...finalOptions, ...bunOptions });
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 5000) {
|
||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||
if (elapsed > 5000 && !path.includes('/stats')) {
|
||||
console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms:`, error);
|
||||
// Log error message only, not full stack trace
|
||||
const msg = error?.message || String(error);
|
||||
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
||||
throw DockerConnectionError.fromError(error);
|
||||
}
|
||||
}
|
||||
@@ -994,12 +1031,31 @@ export async function updateContainer(id: string, options: CreateContainerOption
|
||||
|
||||
// Image operations
|
||||
export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
|
||||
const images = await dockerJsonRequest<any[]>('/images/json', {}, envId);
|
||||
// Fetch images and containers in parallel
|
||||
const [images, containers] = await Promise.all([
|
||||
dockerJsonRequest<any[]>('/images/json', {}, envId),
|
||||
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId).catch(() => [] as any[])
|
||||
]);
|
||||
|
||||
// Build a map of imageId -> container count
|
||||
// Docker may return -1 for Containers field on some hosts, so we compute it ourselves
|
||||
const imageContainerCount = new Map<string, number>();
|
||||
for (const container of containers) {
|
||||
const imageId = container.ImageID || container.Image;
|
||||
if (imageId) {
|
||||
imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return images.map((image) => ({
|
||||
id: image.Id,
|
||||
repoTags: image.RepoTags || [],
|
||||
tags: image.RepoTags || [],
|
||||
size: image.Size,
|
||||
created: image.Created
|
||||
virtualSize: image.VirtualSize || image.Size,
|
||||
created: image.Created,
|
||||
labels: image.Labels || {},
|
||||
containers: imageContainerCount.get(image.Id) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1943,7 +1999,10 @@ export async function pruneContainers(envId?: number | null) {
|
||||
}
|
||||
|
||||
export async function pruneImages(dangling = true, envId?: number | null) {
|
||||
const filters = dangling ? '{"dangling":["true"]}' : '{}';
|
||||
// dangling=true: only remove untagged images (default Docker behavior)
|
||||
// dangling=false: remove ALL unused images including tagged ones
|
||||
// Docker API quirk: to remove all unused, we pass dangling=false filter
|
||||
const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
|
||||
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
|
||||
}
|
||||
|
||||
@@ -2042,18 +2101,30 @@ export async function execInContainer(
|
||||
}
|
||||
|
||||
// Get Docker events as a stream (for SSE)
|
||||
// For streaming mode: call with just filters
|
||||
// For polling mode: call with since and until to get a finite window of events
|
||||
export async function getDockerEvents(
|
||||
filters: Record<string, string[]>,
|
||||
envId?: number | null
|
||||
envId?: number | null,
|
||||
options?: { since?: string; until?: string }
|
||||
): Promise<ReadableStream<Uint8Array> | null> {
|
||||
const filterJson = JSON.stringify(filters);
|
||||
|
||||
// Build query string with optional since/until for polling mode
|
||||
let queryString = `filters=${encodeURIComponent(filterJson)}`;
|
||||
if (options?.since) {
|
||||
queryString += `&since=${encodeURIComponent(options.since)}`;
|
||||
}
|
||||
if (options?.until) {
|
||||
queryString += `&until=${encodeURIComponent(options.until)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: We use streaming: true to disable Bun's idle timeout for this long-lived connection.
|
||||
// The Docker events API keeps the connection open indefinitely, sending events as they occur.
|
||||
// Without streaming: true, Bun would terminate the connection after ~5 seconds of inactivity.
|
||||
const response = await dockerFetch(
|
||||
`/events?filters=${encodeURIComponent(filterJson)}`,
|
||||
`/events?${queryString}`,
|
||||
{ streaming: true },
|
||||
envId
|
||||
);
|
||||
@@ -3114,8 +3185,12 @@ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise<n
|
||||
}
|
||||
|
||||
return removed;
|
||||
} catch (err) {
|
||||
console.warn('Failed to query stale volume helpers:', err);
|
||||
} catch (err: any) {
|
||||
// Don't spam logs for expected failures (e.g., edge agent offline)
|
||||
const msg = err?.message || String(err);
|
||||
if (!msg.includes('not connected') && !msg.includes('offline')) {
|
||||
console.warn('Failed to query stale volume helpers:', msg);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { db, hawserTokens, environments, eq } from './db/drizzle.js';
|
||||
import { logContainerEvent, saveHostMetric, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
|
||||
// Protocol constants
|
||||
export const HAWSER_PROTOCOL_VERSION = '1.0';
|
||||
@@ -243,7 +245,7 @@ export async function validateHawserToken(
|
||||
// Check each token (tokens are hashed)
|
||||
for (const t of tokens) {
|
||||
try {
|
||||
const isValid = await Bun.password.verify(token, t.token);
|
||||
const isValid = await verifyPassword(token, t.token);
|
||||
if (isValid) {
|
||||
// Update last used timestamp
|
||||
await db
|
||||
@@ -292,16 +294,12 @@ export async function generateHawserToken(
|
||||
} else {
|
||||
// Generate a secure random token (32 bytes = 256 bits)
|
||||
const tokenBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(tokenBytes);
|
||||
secureGetRandomValues(tokenBytes);
|
||||
token = Buffer.from(tokenBytes).toString('base64url');
|
||||
}
|
||||
|
||||
// Hash the token for storage (using Bun's built-in Argon2id)
|
||||
const hashedToken = await Bun.password.hash(token, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 19456,
|
||||
timeCost: 2
|
||||
});
|
||||
// Hash the token for storage (using Argon2id)
|
||||
const hashedToken = await hashPassword(token);
|
||||
|
||||
// Get prefix for identification
|
||||
const tokenPrefix = token.substring(0, 8);
|
||||
@@ -477,7 +475,7 @@ export async function sendEdgeRequest(
|
||||
throw new Error('Edge agent not connected');
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestId = secureRandomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
@@ -614,7 +612,7 @@ export function sendEdgeStreamRequest(
|
||||
return { requestId: '', cancel: () => {} };
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestId = secureRandomUUID();
|
||||
|
||||
// Initialize pendingStreamRequests if not present (can happen in dev mode due to HMR)
|
||||
if (!connection.pendingStreamRequests) {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { saveHostMetric, getEnvironments, getEnvSetting } from './db';
|
||||
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from './docker';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import os from 'node:os';
|
||||
|
||||
const COLLECT_INTERVAL = 10000; // 10 seconds
|
||||
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
|
||||
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
|
||||
|
||||
let collectorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let diskCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Track last disk warning sent per environment to avoid spamming
|
||||
const lastDiskWarning: Map<number, number> = new Map();
|
||||
const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings
|
||||
|
||||
/**
|
||||
* Collect metrics for a single environment
|
||||
*/
|
||||
async function collectEnvMetrics(env: { id: number; name: string; collectMetrics?: boolean }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get running containers
|
||||
const containers = await listContainers(false, env.id); // Only running
|
||||
let totalCpuPercent = 0;
|
||||
let totalMemUsed = 0;
|
||||
|
||||
// Get stats for each running container
|
||||
const statsPromises = containers.map(async (container) => {
|
||||
try {
|
||||
const stats = await getContainerStats(container.id, env.id) as any;
|
||||
|
||||
// Calculate CPU percentage
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length;
|
||||
|
||||
let cpuPercent = 0;
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
|
||||
// Get container memory usage
|
||||
const memUsage = stats.memory_stats?.usage || 0;
|
||||
const memCache = stats.memory_stats?.stats?.cache || 0;
|
||||
// Subtract cache from usage to get actual memory used by the container
|
||||
const actualMemUsed = memUsage - memCache;
|
||||
|
||||
return { cpu: cpuPercent, mem: actualMemUsed > 0 ? actualMemUsed : memUsage };
|
||||
} catch {
|
||||
return { cpu: 0, mem: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
totalCpuPercent = statsResults.reduce((sum, v) => sum + v.cpu, 0);
|
||||
totalMemUsed = statsResults.reduce((sum, v) => sum + v.mem, 0);
|
||||
|
||||
// Get host total memory from Docker info (this is the remote host's memory)
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
const memTotal = info.MemTotal || os.totalmem();
|
||||
|
||||
// Calculate memory percentage based on container usage vs host total
|
||||
const memPercent = memTotal > 0 ? (totalMemUsed / memTotal) * 100 : 0;
|
||||
|
||||
// Normalize CPU by number of cores from the remote host
|
||||
const cpuCount = info.NCPU || os.cpus().length;
|
||||
const normalizedCpu = totalCpuPercent / cpuCount;
|
||||
|
||||
// Save to database
|
||||
await saveHostMetric(
|
||||
normalizedCpu,
|
||||
memPercent,
|
||||
totalMemUsed,
|
||||
memTotal,
|
||||
env.id
|
||||
);
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails (might be offline)
|
||||
console.error(`Failed to collect metrics for ${env.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectMetrics() {
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
|
||||
// Filter enabled environments and collect metrics in parallel
|
||||
const enabledEnvs = environments.filter(env => env.collectMetrics !== false);
|
||||
|
||||
// Process all environments in parallel for better performance
|
||||
await Promise.all(enabledEnvs.map(env => collectEnvMetrics(env)));
|
||||
} catch (error) {
|
||||
console.error('Metrics collection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for a single environment
|
||||
*/
|
||||
async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in cooldown for this environment
|
||||
const lastWarningTime = lastDiskWarning.get(env.id);
|
||||
if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) {
|
||||
return; // Skip this environment, still in cooldown
|
||||
}
|
||||
|
||||
// Get Docker disk usage data
|
||||
const diskData = await getDiskUsage(env.id) as any;
|
||||
if (!diskData) return;
|
||||
|
||||
// Calculate total Docker disk usage using reduce for cleaner code
|
||||
let totalUsed = 0;
|
||||
if (diskData.Images) {
|
||||
totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0);
|
||||
}
|
||||
if (diskData.Containers) {
|
||||
totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0);
|
||||
}
|
||||
if (diskData.Volumes) {
|
||||
totalUsed += diskData.Volumes.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0);
|
||||
}
|
||||
if (diskData.BuildCache) {
|
||||
totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0);
|
||||
}
|
||||
|
||||
// Get Docker root filesystem info from Docker info
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
const driverStatus = info?.DriverStatus;
|
||||
|
||||
// Try to find "Data Space Total" from driver status
|
||||
let dataSpaceTotal = 0;
|
||||
let diskPercentUsed = 0;
|
||||
|
||||
if (driverStatus) {
|
||||
for (const [key, value] of driverStatus) {
|
||||
if (key === 'Data Space Total' && typeof value === 'string') {
|
||||
dataSpaceTotal = parseSize(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found total disk space, calculate percentage
|
||||
if (dataSpaceTotal > 0) {
|
||||
diskPercentUsed = (totalUsed / dataSpaceTotal) * 100;
|
||||
} else {
|
||||
// Fallback: just report absolute usage if we can't determine percentage
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
if (totalUsed > 50 * GB) {
|
||||
await sendEventNotification('disk_space_warning', {
|
||||
title: 'High Docker disk usage',
|
||||
message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space`,
|
||||
type: 'warning'
|
||||
}, env.id);
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check against threshold
|
||||
const threshold = await getEnvSetting('disk_warning_threshold', env.id) || DEFAULT_DISK_THRESHOLD;
|
||||
if (diskPercentUsed >= threshold) {
|
||||
console.log(`[Metrics] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`);
|
||||
|
||||
await sendEventNotification('disk_space_warning', {
|
||||
title: 'Disk space warning',
|
||||
message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`,
|
||||
type: 'warning'
|
||||
}, env.id);
|
||||
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails
|
||||
console.error(`Failed to check disk space for ${env.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Docker disk usage and send warnings if above threshold
|
||||
*/
|
||||
async function checkDiskSpace() {
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
|
||||
// Filter enabled environments and check disk space in parallel
|
||||
const enabledEnvs = environments.filter(env => env.collectMetrics !== false);
|
||||
|
||||
// Process all environments in parallel for better performance
|
||||
await Promise.all(enabledEnvs.map(env => checkEnvDiskSpace(env)));
|
||||
} catch (error) {
|
||||
console.error('Disk space check error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse size string like "107.4GB" to bytes
|
||||
*/
|
||||
function parseSize(sizeStr: string): number {
|
||||
const units: Record<string, number> = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
return value * (units[unit] || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let size = bytes;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function startMetricsCollector() {
|
||||
if (collectorInterval) return; // Already running
|
||||
|
||||
console.log('Starting server-side metrics collector (every 10s)');
|
||||
|
||||
// Initial collection
|
||||
collectMetrics();
|
||||
|
||||
// Schedule regular collection
|
||||
collectorInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
|
||||
// Start disk space checking (every 5 minutes)
|
||||
console.log('Starting disk space monitoring (every 5 minutes)');
|
||||
checkDiskSpace(); // Initial check
|
||||
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
export function stopMetricsCollector() {
|
||||
if (collectorInterval) {
|
||||
clearInterval(collectorInterval);
|
||||
collectorInterval = null;
|
||||
}
|
||||
if (diskCheckInterval) {
|
||||
clearInterval(diskCheckInterval);
|
||||
diskCheckInterval = null;
|
||||
}
|
||||
lastDiskWarning.clear();
|
||||
console.log('Metrics collector stopped');
|
||||
}
|
||||
+593
-120
@@ -5,8 +5,8 @@
|
||||
* All lifecycle operations use docker compose commands.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import {
|
||||
getEnvironment,
|
||||
getStackEnvVarsAsRecord,
|
||||
@@ -88,22 +88,6 @@ export interface DeployStackOptions {
|
||||
// ERRORS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Error for operations on external stacks without compose files
|
||||
*/
|
||||
export class ExternalStackError extends Error {
|
||||
public readonly stackName: string;
|
||||
|
||||
constructor(stackName: string) {
|
||||
super(
|
||||
`Stack "${stackName}" was created outside of Dockhand. ` +
|
||||
`To manage this stack, first import it by clicking the Import button in the stack menu.`
|
||||
);
|
||||
this.name = 'ExternalStackError';
|
||||
this.stackName = stackName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error when compose file is missing for a managed stack
|
||||
*/
|
||||
@@ -232,6 +216,65 @@ export function getStacksDir(): string {
|
||||
return _stacksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stack directory path for a specific environment.
|
||||
* New stacks use: $DATA_DIR/stacks/<envName>/<stackName>/
|
||||
* Legacy stacks (no env): $DATA_DIR/stacks/<stackName>/
|
||||
*
|
||||
* Automatically looks up environment name from database.
|
||||
*/
|
||||
export async function getStackDir(stackName: string, envId?: number | null): Promise<string> {
|
||||
const stacksDir = getStacksDir();
|
||||
if (envId) {
|
||||
const env = await getEnvironment(envId);
|
||||
if (env) {
|
||||
return join(stacksDir, env.name, stackName);
|
||||
}
|
||||
}
|
||||
// Legacy path for stacks without environment
|
||||
return join(stacksDir, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stack directory, checking paths in order:
|
||||
* 1. New path (envName): $DATA_DIR/stacks/<envName>/<stackName>/
|
||||
* 2. ID-based path (envId): $DATA_DIR/stacks/<envId>/<stackName>/
|
||||
* 3. Legacy path: $DATA_DIR/stacks/<stackName>/
|
||||
*
|
||||
* Automatically looks up environment name from database.
|
||||
* Always checks legacy path for backwards compatibility with pre-env stacks.
|
||||
*/
|
||||
export async function findStackDir(stackName: string, envId?: number | null): Promise<string | null> {
|
||||
const stacksDir = getStacksDir();
|
||||
|
||||
// Look up environment name if we have an ID
|
||||
if (envId) {
|
||||
const env = await getEnvironment(envId);
|
||||
|
||||
// 1. Check new path (with envName)
|
||||
if (env) {
|
||||
const namePath = join(stacksDir, env.name, stackName);
|
||||
if (existsSync(namePath)) {
|
||||
return namePath;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check ID-based path
|
||||
const idPath = join(stacksDir, String(envId), stackName);
|
||||
if (existsSync(idPath)) {
|
||||
return idPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Always check legacy path (stacks created before env-scoping was added)
|
||||
const legacyPath = join(stacksDir, stackName);
|
||||
if (existsSync(legacyPath)) {
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List stacks that have compose files stored locally
|
||||
*/
|
||||
@@ -256,31 +299,116 @@ export function listManagedStacks(): string[] {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get compose file content for a stack
|
||||
* Result type for getStackComposeFile
|
||||
*/
|
||||
export interface GetComposeFileResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
stackDir?: string;
|
||||
error?: string;
|
||||
needsFileLocation?: boolean;
|
||||
composePath?: string | null;
|
||||
envPath?: string | null;
|
||||
suggestedEnvPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compose file content for a stack.
|
||||
*
|
||||
* Unified logic for all stacks:
|
||||
* - If composePath is set in DB → use custom path
|
||||
* - If composePath is NULL → use default location (data/stacks/{env}/{name}/)
|
||||
* - If no source record and no files found → return needsFileLocation: true
|
||||
*/
|
||||
export async function getStackComposeFile(
|
||||
stackName: string
|
||||
): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<GetComposeFileResult> {
|
||||
const source = await getStackSource(stackName, envId);
|
||||
|
||||
// Check all common compose file names (Docker Compose v1 and v2 naming conventions)
|
||||
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
// Case 1: Stack not in database = untracked (discovered from Docker but not imported)
|
||||
// User must select the compose file location - don't guess from default location
|
||||
if (!source) {
|
||||
return {
|
||||
success: false,
|
||||
needsFileLocation: true,
|
||||
error: `Select the compose file location for stack "${stackName}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Stack has custom composePath set - use it
|
||||
if (source.composePath) {
|
||||
try {
|
||||
if (!existsSync(source.composePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Compose file no longer accessible at ${source.composePath}. The file may have been moved or deleted.`,
|
||||
composePath: source.composePath,
|
||||
envPath: source.envPath
|
||||
};
|
||||
}
|
||||
|
||||
const content = await Bun.file(source.composePath).text();
|
||||
const stackDir = dirname(source.composePath);
|
||||
|
||||
// For custom paths, suggest .env next to compose if envPath not set
|
||||
let suggestedEnvPath: string | undefined;
|
||||
if (source.envPath === null) {
|
||||
suggestedEnvPath = source.composePath.replace(/\/[^/]+$/, '/.env');
|
||||
}
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
const file = Bun.file(join(stackDir, fileName));
|
||||
if (await file.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
stackDir
|
||||
content,
|
||||
stackDir,
|
||||
composePath: source.composePath,
|
||||
envPath: source.envPath,
|
||||
suggestedEnvPath
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read compose file: ${message}`,
|
||||
composePath: source.composePath,
|
||||
envPath: source.envPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Stack is in DB but no custom path - check default location
|
||||
// This is for stacks created in Dockhand using the default data directory
|
||||
const stackDir = await findStackDir(stackName, envId);
|
||||
|
||||
if (stackDir) {
|
||||
// Check all common compose file names
|
||||
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
const actualComposePath = join(stackDir, fileName);
|
||||
const file = Bun.file(actualComposePath);
|
||||
if (await file.exists()) {
|
||||
// Check for .env file in the same directory
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
const envExists = existsSync(envFilePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
stackDir,
|
||||
// Always return the actual resolved paths for display
|
||||
composePath: actualComposePath,
|
||||
envPath: envExists ? envFilePath : null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 4: Stack is in DB but compose file not found - need user to specify location
|
||||
return {
|
||||
success: false,
|
||||
error: `Compose file not found for stack "${stackName}". The stack may have been created outside of Dockhand.`
|
||||
needsFileLocation: true,
|
||||
error: `Select the compose file location for stack "${stackName}"`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,22 +417,206 @@ export async function getStackComposeFile(
|
||||
* @param name - Stack name
|
||||
* @param content - Compose file content
|
||||
* @param create - If true, creates a new stack (fails if exists). If false, updates existing (fails if not exists).
|
||||
* @param envId - Environment ID for path scoping
|
||||
*/
|
||||
export async function saveStackComposeFile(
|
||||
name: string,
|
||||
content: string,
|
||||
create = false
|
||||
create = false,
|
||||
envId?: number | null,
|
||||
options?: {
|
||||
composePath?: string; // Custom compose file path
|
||||
envPath?: string | null; // Custom env path (null = default, '' = none)
|
||||
moveFromDir?: string; // Old directory to move all files from when path changes
|
||||
oldComposePath?: string; // Old compose file path for renaming
|
||||
oldEnvPath?: string; // Old env file path for renaming
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Validate stack name
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
|
||||
// Must also start with a letter or number
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Stack name can only contain letters, numbers, hyphens, and underscores'
|
||||
error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores'
|
||||
};
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, name);
|
||||
// Check if this stack has a custom compose path configured, or if one was provided
|
||||
const source = await getStackSource(name, envId);
|
||||
const composePath = options?.composePath || source?.composePath;
|
||||
|
||||
// Handle compose file move/rename when path changes
|
||||
if (options?.oldComposePath && options?.composePath &&
|
||||
options.oldComposePath !== options.composePath &&
|
||||
existsSync(options.oldComposePath)) {
|
||||
const newDir = dirname(options.composePath);
|
||||
|
||||
// Ensure target directory exists
|
||||
if (!existsSync(newDir)) {
|
||||
try {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move/rename the compose file to new location
|
||||
try {
|
||||
renameSync(options.oldComposePath, options.composePath);
|
||||
console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`);
|
||||
} catch (renameErr: any) {
|
||||
// If rename fails (e.g., cross-filesystem), try copy+delete
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
try {
|
||||
const data = readFileSync(options.oldComposePath);
|
||||
writeFileSync(options.composePath, data);
|
||||
unlinkSync(options.oldComposePath);
|
||||
console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to copy compose file: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle env file move/rename when path changes
|
||||
if (options?.oldEnvPath && options?.envPath &&
|
||||
options.oldEnvPath !== options.envPath &&
|
||||
existsSync(options.oldEnvPath)) {
|
||||
const newDir = dirname(options.envPath);
|
||||
|
||||
// Ensure target directory exists
|
||||
if (!existsSync(newDir)) {
|
||||
try {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move/rename the env file to new location
|
||||
try {
|
||||
renameSync(options.oldEnvPath, options.envPath);
|
||||
console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`);
|
||||
} catch (renameErr: any) {
|
||||
// If rename fails (e.g., cross-filesystem), try copy+delete
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
try {
|
||||
const data = readFileSync(options.oldEnvPath);
|
||||
writeFileSync(options.envPath, data);
|
||||
unlinkSync(options.oldEnvPath);
|
||||
console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to copy env file: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Stack] Failed to move env file: ${renameErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move all files from old directory to new directory when path changes
|
||||
// Get the new directory from composePath
|
||||
const newDir = options?.composePath ? dirname(options.composePath) : null;
|
||||
|
||||
if (options?.moveFromDir && newDir && options.moveFromDir !== newDir && existsSync(options.moveFromDir)) {
|
||||
try {
|
||||
// Ensure new directory exists
|
||||
if (!existsSync(newDir)) {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move all files from old directory to new directory
|
||||
const files = readdirSync(options.moveFromDir);
|
||||
for (const file of files) {
|
||||
const oldFilePath = join(options.moveFromDir, file);
|
||||
const newFilePath = join(newDir, file);
|
||||
|
||||
try {
|
||||
// Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem
|
||||
renameSync(oldFilePath, newFilePath);
|
||||
console.log(`[Stack] Moved file: ${oldFilePath} -> ${newFilePath}`);
|
||||
} catch (renameErr: any) {
|
||||
// If rename fails (e.g., cross-filesystem), try copy+delete
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
const stat = statSync(oldFilePath);
|
||||
if (stat.isDirectory()) {
|
||||
// For directories, use recursive copy
|
||||
cpSync(oldFilePath, newFilePath, { recursive: true });
|
||||
rmSync(oldFilePath, { recursive: true, force: true });
|
||||
} else {
|
||||
// For files, read and write
|
||||
const data = readFileSync(oldFilePath);
|
||||
writeFileSync(newFilePath, data);
|
||||
unlinkSync(oldFilePath);
|
||||
}
|
||||
console.log(`[Stack] Copied file (cross-fs): ${oldFilePath} -> ${newFilePath}`);
|
||||
} else {
|
||||
throw renameErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old directory if it's now empty
|
||||
try {
|
||||
const remaining = readdirSync(options.moveFromDir);
|
||||
if (remaining.length === 0) {
|
||||
rmSync(options.moveFromDir, { recursive: true, force: true });
|
||||
console.log(`[Stack] Removed empty old directory: ${options.moveFromDir}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when checking/removing old directory
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to move files from ${options.moveFromDir} to ${newDir}: ${err.message}`);
|
||||
// Continue with save even if move fails - new files will be written anyway
|
||||
}
|
||||
}
|
||||
|
||||
// If a custom composePath is being set (new or update), save it to the database
|
||||
if (options?.composePath || options?.envPath !== undefined) {
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envId ?? null,
|
||||
sourceType: 'internal',
|
||||
composePath: options?.composePath || source?.composePath || null,
|
||||
envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null)
|
||||
});
|
||||
}
|
||||
|
||||
if (composePath) {
|
||||
// Write directly to the custom compose file path
|
||||
// Ensure parent directory exists for custom paths
|
||||
const parentDir = dirname(composePath);
|
||||
if (!existsSync(parentDir)) {
|
||||
try {
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to create directory for compose file: ${err.message}` };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Bun.write(composePath, content);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to save compose file: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// For creates, use new path; for updates, find existing path first
|
||||
let stackDir: string;
|
||||
if (create) {
|
||||
stackDir = await getStackDir(name, envId);
|
||||
} else {
|
||||
const existingDir = await findStackDir(name, envId);
|
||||
if (!existingDir) {
|
||||
return { success: false, error: `Stack "${name}" not found` };
|
||||
}
|
||||
stackDir = existingDir;
|
||||
}
|
||||
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
const exists = existsSync(stackDir);
|
||||
|
||||
@@ -323,11 +635,6 @@ export async function saveStackComposeFile(
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to create stack directory: ${err.message}` };
|
||||
}
|
||||
} else {
|
||||
// Updating existing stack - must exist
|
||||
if (!exists) {
|
||||
return { success: false, error: `Stack "${name}" not found` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -338,6 +645,68 @@ export async function saveStackComposeFile(
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REGISTRY AUTHENTICATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Login to all configured Docker registries before running compose commands.
|
||||
* This ensures that `docker compose up` can pull images from private registries.
|
||||
*/
|
||||
async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Promise<void> {
|
||||
const { getRegistries } = await import('./db.js');
|
||||
const registries = await getRegistries();
|
||||
|
||||
if (registries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnEnv: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
if (dockerHost) {
|
||||
spawnEnv.DOCKER_HOST = dockerHost;
|
||||
}
|
||||
|
||||
for (const reg of registries) {
|
||||
if (!reg.username || !reg.password) {
|
||||
continue; // Skip registries without credentials
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract registry host from URL
|
||||
const url = new URL(reg.url);
|
||||
const registryHost = url.host;
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['docker', 'login', '-u', reg.username, '--password-stdin', registryHost],
|
||||
{
|
||||
env: spawnEnv,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
}
|
||||
);
|
||||
|
||||
// Write password to stdin
|
||||
const writer = proc.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode(reg.password));
|
||||
await writer.close();
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log(`${logPrefix} Successfully logged into ${registryHost}`);
|
||||
} else {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`${logPrefix} Error logging into registry ${reg.name}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPOSE COMMAND EXECUTION
|
||||
// =============================================================================
|
||||
@@ -364,11 +733,14 @@ async function executeLocalCompose(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>,
|
||||
forceRecreate?: boolean,
|
||||
removeVolumes?: boolean
|
||||
removeVolumes?: boolean,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
// For operations that write (up), use getStackDir; for others, try to find existing first
|
||||
const stackDir = operation === 'up'
|
||||
? await getStackDir(stackName, envId)
|
||||
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
@@ -433,6 +805,11 @@ async function executeLocalCompose(
|
||||
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
}
|
||||
|
||||
// Login to registries before pulling images
|
||||
if (operation === 'up' || operation === 'pull') {
|
||||
await loginToRegistries(dockerHost, logPrefix);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`${logPrefix} Spawning docker compose process...`);
|
||||
const proc = Bun.spawn(args, {
|
||||
@@ -577,6 +954,20 @@ async function executeComposeViaHawser(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch registry credentials for Hawser to use for docker login
|
||||
const { getRegistries } = await import('./db.js');
|
||||
const allRegistries = await getRegistries();
|
||||
const registries = allRegistries
|
||||
.filter(r => r.username && r.password)
|
||||
.map(r => ({
|
||||
url: r.url,
|
||||
username: r.username!,
|
||||
password: r.password!
|
||||
}));
|
||||
if (registries.length > 0) {
|
||||
console.log(`${logPrefix} Sending ${registries.length} registry credentials to Hawser`);
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
operation,
|
||||
projectName: stackName,
|
||||
@@ -584,7 +975,8 @@ async function executeComposeViaHawser(
|
||||
envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env
|
||||
files, // Files including .env (secrets NOT in .env file)
|
||||
forceRecreate: forceRecreate || false,
|
||||
removeVolumes: removeVolumes || false
|
||||
removeVolumes: removeVolumes || false,
|
||||
registries // Registry credentials for docker login
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Sending request to Hawser agent...`);
|
||||
@@ -665,7 +1057,8 @@ async function executeComposeCommand(
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -695,7 +1088,8 @@ async function executeComposeCommand(
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -709,7 +1103,8 @@ async function executeComposeCommand(
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
envId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -806,6 +1201,37 @@ async function getStackContainers(stackName: string, envId?: number | null): Pro
|
||||
return containers.filter((c) => c.labels['com.docker.compose.project'] === stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path hints from Docker container labels for a stack.
|
||||
* Docker Compose adds labels like:
|
||||
* - com.docker.compose.project.working_dir: /path/to/stack
|
||||
* - com.docker.compose.project.config_files: /path/to/docker-compose.yml[,...]
|
||||
*/
|
||||
export async function getStackPathHints(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{
|
||||
workingDir: string | null;
|
||||
configFiles: string[] | null;
|
||||
}> {
|
||||
const containers = await getStackContainers(stackName, envId);
|
||||
|
||||
if (containers.length === 0) {
|
||||
return { workingDir: null, configFiles: null };
|
||||
}
|
||||
|
||||
// Get labels from first container (all containers in stack have same project labels)
|
||||
const labels = containers[0].labels || {};
|
||||
|
||||
const workingDir = labels['com.docker.compose.project.working_dir'] || null;
|
||||
const configFilesRaw = labels['com.docker.compose.project.config_files'] || null;
|
||||
|
||||
// Config files can be comma-separated if multiple compose files were used
|
||||
const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null;
|
||||
|
||||
return { workingDir, configFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to perform container-based operations for external stacks
|
||||
* Used as fallback when no compose file exists.
|
||||
@@ -878,12 +1304,25 @@ async function withContainerFallback(
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ensure we have a compose file for operations, throw appropriate error if not.
|
||||
* Result type for requireComposeFile - can indicate stack needs file location
|
||||
*/
|
||||
export interface RequireComposeResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
envVars?: Record<string, string>;
|
||||
secretVars?: Record<string, string>;
|
||||
needsFileLocation?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compose file and env vars for stack operations.
|
||||
*
|
||||
* Returns:
|
||||
* - content: The compose file content
|
||||
* - envVars: Non-secret variables (from .env file, with DB fallback)
|
||||
* - secretVars: Secret variables (from DB only, for shell injection)
|
||||
* - needsFileLocation: true if stack needs user to specify file paths
|
||||
*
|
||||
* SECURITY: Secrets are NEVER written to .env files. They are stored in the database
|
||||
* and injected via shell environment variables at runtime.
|
||||
@@ -891,16 +1330,22 @@ async function withContainerFallback(
|
||||
async function requireComposeFile(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{ content: string; envVars: Record<string, string>; secretVars: Record<string, string> }> {
|
||||
const composeResult = await getStackComposeFile(stackName);
|
||||
): Promise<RequireComposeResult> {
|
||||
const composeResult = await getStackComposeFile(stackName, envId);
|
||||
|
||||
// If compose file not found, return info about what's needed
|
||||
if (!composeResult.success) {
|
||||
// Check if this is an external stack
|
||||
const source = await getStackSource(stackName, envId);
|
||||
if (!source || source.sourceType === 'external') {
|
||||
throw new ExternalStackError(stackName);
|
||||
if (composeResult.needsFileLocation) {
|
||||
return {
|
||||
success: false,
|
||||
needsFileLocation: true,
|
||||
error: composeResult.error
|
||||
};
|
||||
}
|
||||
throw new ComposeFileNotFoundError(stackName);
|
||||
return {
|
||||
success: false,
|
||||
error: composeResult.error || `Compose file not found for stack "${stackName}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Get SECRET variables from database (for shell injection at runtime)
|
||||
@@ -910,12 +1355,26 @@ async function requireComposeFile(
|
||||
// Get non-secret variables from database (for backward compatibility)
|
||||
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Read non-secret vars from .env file (user can edit this file manually)
|
||||
const stackDir = join(getStacksDir(), stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
// Read non-secret vars from .env file
|
||||
// For stacks with custom path, use the env path if set (and not empty string which means "no env file")
|
||||
// Otherwise, use the .env file in the stack directory
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (composeResult.composePath && composeResult.envPath) {
|
||||
// Custom compose path with explicit env path
|
||||
envFilePath = composeResult.envPath;
|
||||
} else if (composeResult.composePath && composeResult.envPath === '') {
|
||||
// Custom compose path with explicit "no env file" - don't read any file
|
||||
envFilePath = null;
|
||||
} else {
|
||||
// Default location - look for .env in stack directory
|
||||
const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
|
||||
let fileEnvVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
for (const line of content.split('\n')) {
|
||||
@@ -941,85 +1400,80 @@ async function requireComposeFile(
|
||||
// This ensures external edits to .env are respected during deployment
|
||||
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
|
||||
|
||||
return { content: composeResult.content!, envVars, secretVars };
|
||||
return { success: true, content: composeResult.content!, envVars, secretVars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stack using docker compose up
|
||||
* Falls back to individual container start for external stacks
|
||||
* Falls back to individual container start for stacks without compose files
|
||||
*/
|
||||
export async function startStack(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('up', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - fall back to container-based operations
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
|
||||
return executeComposeCommand('up', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a stack using docker compose stop
|
||||
* Falls back to individual container stop for external stacks
|
||||
* Falls back to individual container stop for stacks without compose files
|
||||
*/
|
||||
export async function stopStack(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('stop', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - fall back to container-based operations
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand('stop', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a stack using docker compose restart
|
||||
* Falls back to individual container restart for external stacks
|
||||
* Falls back to individual container restart for stacks without compose files
|
||||
*/
|
||||
export async function restartStack(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('restart', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - fall back to container-based operations
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
|
||||
return executeComposeCommand('restart', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Down a stack using docker compose down (removes containers, keeps files)
|
||||
* For external stacks, this is equivalent to stop (no compose file to "down")
|
||||
* For stacks without compose files, this is equivalent to stop
|
||||
*/
|
||||
export async function downStack(
|
||||
stackName: string,
|
||||
envId?: number | null,
|
||||
removeVolumes = false
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
// For external stacks, down is the same as stop (no compose file to tear down)
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - down is the same as stop
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1080,8 +1534,7 @@ export async function removeStack(
|
||||
const cleanupErrors: string[] = [];
|
||||
|
||||
// Delete compose file and directory
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const stackDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
|
||||
if (existsSync(stackDir)) {
|
||||
try {
|
||||
rmSync(stackDir, { recursive: true, force: true });
|
||||
@@ -1166,19 +1619,19 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2));
|
||||
}
|
||||
|
||||
// Validate stack name
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
|
||||
// Must also start with a letter or number
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
||||
console.log(`${logPrefix} ERROR: Invalid stack name format`);
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Stack name can only contain letters, numbers, hyphens, and underscores'
|
||||
error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores'
|
||||
};
|
||||
}
|
||||
|
||||
return withStackLock(name, async () => {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, name);
|
||||
const stackDir = await getStackDir(name, envId);
|
||||
|
||||
// Read all files from source directory if provided (for Hawser deployments)
|
||||
let stackFiles: Record<string, string> | undefined;
|
||||
@@ -1248,9 +1701,16 @@ export async function pullStackImages(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
return executeComposeCommand('pull', { stackName, envId }, content, envVars, secretVars);
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Compose file not found'
|
||||
};
|
||||
}
|
||||
|
||||
return executeComposeCommand('pull', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1279,10 +1739,19 @@ export async function saveStackEnvVarsToDb(
|
||||
*/
|
||||
export async function writeStackEnvFile(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[]
|
||||
variables: { key: string; value: string; isSecret?: boolean }[],
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
const envFilePath = customEnvPath
|
||||
? customEnvPath
|
||||
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = dirname(envFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// SECURITY: Only write non-secret variables to .env file
|
||||
// Secrets are stored in DB and injected via shell environment at runtime
|
||||
@@ -1302,17 +1771,20 @@ export async function writeStackEnvFile(
|
||||
*/
|
||||
export async function writeRawStackEnvFile(
|
||||
stackName: string,
|
||||
rawContent: string
|
||||
rawContent: string,
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const envFilePath = customEnvPath
|
||||
? customEnvPath
|
||||
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
|
||||
// Ensure stack directory exists
|
||||
if (!existsSync(stackDir)) {
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
// Ensure parent directory exists
|
||||
const dir = dirname(envFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
@@ -1329,12 +1801,13 @@ export async function writeRawStackEnvFile(
|
||||
export async function saveStackEnvVars(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[],
|
||||
envId?: number | null
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
// Save to database for secret tracking
|
||||
await saveStackEnvVarsToDb(stackName, variables, envId);
|
||||
// Write .env file to disk for Docker Compose
|
||||
await writeStackEnvFile(stackName, variables);
|
||||
await writeStackEnvFile(stackName, variables, envId, customEnvPath);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -102,7 +102,12 @@ export interface ShutdownCommand {
|
||||
type: 'shutdown';
|
||||
}
|
||||
|
||||
export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand;
|
||||
export interface UpdateIntervalCommand {
|
||||
type: 'update_interval';
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand | UpdateIntervalCommand;
|
||||
|
||||
// Subprocess configuration
|
||||
interface SubprocessConfig {
|
||||
@@ -198,6 +203,20 @@ class SubprocessManager {
|
||||
this.sendToEvents({ type: 'refresh_environments' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to metrics subprocess
|
||||
*/
|
||||
sendToMetricsSubprocess(message: MainProcessCommand): void {
|
||||
this.sendToMetrics(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to events subprocess
|
||||
*/
|
||||
sendToEventsSubprocess(message: MainProcessCommand): void {
|
||||
this.sendToEvents(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metrics collection subprocess
|
||||
*/
|
||||
@@ -591,3 +610,21 @@ export function refreshSubprocessEnvironments(): void {
|
||||
manager.refreshEnvironments();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to event subprocess
|
||||
*/
|
||||
export function sendToEventSubprocess(message: MainProcessCommand): void {
|
||||
if (manager) {
|
||||
manager.sendToEventsSubprocess(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to metrics subprocess
|
||||
*/
|
||||
export function sendToMetricsSubprocess(message: MainProcessCommand): void {
|
||||
if (manager) {
|
||||
manager.sendToMetricsSubprocess(message);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, type ContainerEventAction } from '../db';
|
||||
import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db';
|
||||
import { getDockerEvents } from '../docker';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
@@ -19,9 +19,15 @@ const MAX_RECONNECT_DELAY = 60000; // 1 minute max
|
||||
// Only send notifications on status CHANGES, not on every reconnect attempt
|
||||
const environmentOnlineStatus: Map<number, boolean> = new Map();
|
||||
|
||||
// Active collectors per environment
|
||||
// Active collectors per environment (for streaming mode)
|
||||
const collectors: Map<number, AbortController> = new Map();
|
||||
|
||||
// Poll intervals per environment (for polling mode)
|
||||
const pollIntervals: Map<number, ReturnType<typeof setInterval>> = new Map();
|
||||
|
||||
// Last poll timestamp per environment (for polling mode)
|
||||
const lastPollTime: Map<number, number> = new Map();
|
||||
|
||||
// Recent event cache for deduplication (key: timeNano-containerId-action)
|
||||
const recentEvents: Map<string, number> = new Map();
|
||||
const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication
|
||||
@@ -30,6 +36,10 @@ const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds
|
||||
let cacheCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Track current settings to detect changes
|
||||
let currentPollInterval: number = 60000;
|
||||
let currentMode: 'stream' | 'poll' = 'stream';
|
||||
|
||||
// Actions we care about for container activity
|
||||
const CONTAINER_ACTIONS: ContainerEventAction[] = [
|
||||
'create',
|
||||
@@ -211,6 +221,76 @@ function processEvent(event: DockerEvent, envId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll events for a specific environment (polling mode)
|
||||
*/
|
||||
async function pollEnvironmentEvents(envId: number, envName: string) {
|
||||
try {
|
||||
// Calculate 'since' timestamp (use last poll time, or start from 30s ago if first poll)
|
||||
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
|
||||
const since = lastPollTime.get(envId) || (now - 30); // Default to 30s ago on first poll
|
||||
|
||||
// Fetch events since last check until now
|
||||
// IMPORTANT: 'until' is required for polling mode, otherwise Docker keeps the connection open
|
||||
const eventStream = await getDockerEvents(
|
||||
{ type: ['container'] },
|
||||
envId,
|
||||
{ since: since.toString(), until: now.toString() }
|
||||
);
|
||||
|
||||
if (!eventStream) {
|
||||
console.error(`[EventSubprocess] Failed to fetch events for ${envName}`);
|
||||
updateEnvironmentStatus(envId, envName, false, 'Failed to fetch Docker events');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark environment as online
|
||||
updateEnvironmentStatus(envId, envName, true);
|
||||
|
||||
// Read and process all events
|
||||
const reader = eventStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as DockerEvent;
|
||||
processEvent(event, envId);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released
|
||||
}
|
||||
}
|
||||
|
||||
// Update last poll time
|
||||
lastPollTime.set(envId, now);
|
||||
|
||||
} catch (error: any) {
|
||||
if (!isShuttingDown) {
|
||||
console.error(`[EventSubprocess] Poll error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start collecting events for a specific environment
|
||||
*/
|
||||
@@ -332,7 +412,42 @@ async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop collecting events for a specific environment
|
||||
* Start polling mode for a specific environment
|
||||
*/
|
||||
async function startEnvironmentPoller(envId: number, envName: string, interval: number) {
|
||||
// Stop existing poller if any
|
||||
stopEnvironmentPoller(envId);
|
||||
|
||||
console.log(`[EventSubprocess] Starting poller for ${envName} (every ${interval / 1000}s)`);
|
||||
|
||||
// Initial poll immediately
|
||||
await pollEnvironmentEvents(envId, envName);
|
||||
|
||||
// Set up interval for subsequent polls
|
||||
const intervalId = setInterval(async () => {
|
||||
if (!isShuttingDown) {
|
||||
await pollEnvironmentEvents(envId, envName);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
pollIntervals.set(envId, intervalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for a specific environment
|
||||
*/
|
||||
function stopEnvironmentPoller(envId: number) {
|
||||
const intervalId = pollIntervals.get(envId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
pollIntervals.delete(envId);
|
||||
lastPollTime.delete(envId);
|
||||
environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop collecting events for a specific environment (streaming mode)
|
||||
*/
|
||||
function stopEnvironmentCollector(envId: number) {
|
||||
const controller = collectors.get(envId);
|
||||
@@ -351,6 +466,21 @@ async function refreshEventCollectors() {
|
||||
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
const mode = await getEventCollectionMode();
|
||||
const pollInterval = await getEventPollInterval();
|
||||
|
||||
// Detect if settings changed
|
||||
const modeChanged = mode !== currentMode;
|
||||
const intervalChanged = pollInterval !== currentPollInterval;
|
||||
|
||||
if (modeChanged) {
|
||||
console.log(`[EventSubprocess] Mode changed from ${currentMode} to ${mode}`);
|
||||
currentMode = mode;
|
||||
}
|
||||
if (intervalChanged) {
|
||||
console.log(`[EventSubprocess] Poll interval changed from ${currentPollInterval}ms to ${pollInterval}ms`);
|
||||
currentPollInterval = pollInterval;
|
||||
}
|
||||
|
||||
// Filter: only collect for environments with activity enabled AND not Hawser Edge
|
||||
const activeEnvIds = new Set(
|
||||
@@ -362,18 +492,55 @@ async function refreshEventCollectors() {
|
||||
// Stop collectors for removed environments or those with collection disabled
|
||||
for (const envId of collectors.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping collector for environment ${envId}`);
|
||||
console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`);
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// Start collectors for environments with collection enabled
|
||||
// Stop pollers for removed environments or those with collection disabled
|
||||
// Also restart all pollers if interval changed
|
||||
for (const envId of pollIntervals.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping poller for environment ${envId}`);
|
||||
stopEnvironmentPoller(envId);
|
||||
} else if (intervalChanged && mode === 'poll') {
|
||||
// Restart poller with new interval
|
||||
console.log(`[EventSubprocess] Restarting poller for environment ${envId} with new interval`);
|
||||
stopEnvironmentPoller(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// Start collectors based on mode
|
||||
for (const env of environments) {
|
||||
// Skip Hawser Edge (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') continue;
|
||||
|
||||
if (env.collectActivity && !collectors.has(env.id)) {
|
||||
startEnvironmentCollector(env.id, env.name);
|
||||
// Skip if activity collection is disabled
|
||||
if (!env.collectActivity) continue;
|
||||
|
||||
const hasStreamCollector = collectors.has(env.id);
|
||||
const hasPoller = pollIntervals.has(env.id);
|
||||
|
||||
if (mode === 'stream') {
|
||||
// Switch from polling to streaming if needed
|
||||
if (hasPoller) {
|
||||
console.log(`[EventSubprocess] Switching ${env.name} from poll to stream`);
|
||||
stopEnvironmentPoller(env.id);
|
||||
}
|
||||
// Start stream if not already running
|
||||
if (!hasStreamCollector) {
|
||||
startEnvironmentCollector(env.id, env.name);
|
||||
}
|
||||
} else if (mode === 'poll') {
|
||||
// Switch from streaming to polling if needed
|
||||
if (hasStreamCollector) {
|
||||
console.log(`[EventSubprocess] Switching ${env.name} from stream to poll`);
|
||||
stopEnvironmentCollector(env.id);
|
||||
}
|
||||
// Start poller if not already running (will also restart after interval change above)
|
||||
if (!hasPoller) {
|
||||
startEnvironmentPoller(env.id, env.name, pollInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -392,6 +559,13 @@ function handleCommand(command: MainProcessCommand): void {
|
||||
refreshEventCollectors();
|
||||
break;
|
||||
|
||||
case 'update_interval':
|
||||
// This is used by metrics subprocess, but we handle it here too for consistency
|
||||
// Event subprocess re-reads interval from DB on refresh
|
||||
console.log('[EventSubprocess] Interval update - refreshing collectors...');
|
||||
refreshEventCollectors();
|
||||
break;
|
||||
|
||||
case 'shutdown':
|
||||
console.log('[EventSubprocess] Shutdown requested');
|
||||
shutdown();
|
||||
@@ -411,11 +585,16 @@ function shutdown(): void {
|
||||
cacheCleanupInterval = null;
|
||||
}
|
||||
|
||||
// Stop all environment collectors
|
||||
// Stop all environment stream collectors
|
||||
for (const envId of collectors.keys()) {
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
|
||||
// Stop all environment pollers
|
||||
for (const envId of pollIntervals.keys()) {
|
||||
stopEnvironmentPoller(envId);
|
||||
}
|
||||
|
||||
// Clear the deduplication cache
|
||||
recentEvents.clear();
|
||||
|
||||
@@ -429,6 +608,15 @@ function shutdown(): void {
|
||||
async function start(): Promise<void> {
|
||||
console.log('[EventSubprocess] Starting container event collection...');
|
||||
|
||||
// Initialize current settings from database
|
||||
try {
|
||||
currentMode = await getEventCollectionMode();
|
||||
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);
|
||||
}
|
||||
|
||||
// Start collectors for all environments
|
||||
await refreshEventCollectors();
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, getEnvSetting } from '../db';
|
||||
import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db';
|
||||
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker';
|
||||
import os from 'node:os';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
const COLLECT_INTERVAL = 10000; // 10 seconds
|
||||
let COLLECT_INTERVAL = 30000; // 30 seconds (default, will be loaded from settings)
|
||||
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
|
||||
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
|
||||
const ENV_METRICS_TIMEOUT = 15000; // 15 seconds timeout per environment for metrics
|
||||
@@ -82,12 +82,16 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string;
|
||||
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
|
||||
// Get container memory usage (subtract cache for actual usage)
|
||||
// Get container memory usage using the same formula as Docker CLI
|
||||
// Docker subtracts cache (inactive_file) from total usage
|
||||
// - cgroup v2: uses 'inactive_file'
|
||||
// - cgroup v1: uses 'total_inactive_file'
|
||||
const memUsage = stats.memory_stats?.usage || 0;
|
||||
const memCache = stats.memory_stats?.stats?.cache || 0;
|
||||
const actualMemUsed = memUsage - memCache;
|
||||
const memStats = stats.memory_stats?.stats || {};
|
||||
const memCache = memStats.inactive_file ?? memStats.total_inactive_file ?? 0;
|
||||
const actualMemUsed = memCache > 0 && memCache < memUsage ? memUsage - memCache : memUsage;
|
||||
|
||||
return { cpuPercent, memUsage: actualMemUsed > 0 ? actualMemUsed : memUsage };
|
||||
return { cpuPercent, memUsage: actualMemUsed };
|
||||
} catch {
|
||||
return { cpuPercent: 0, memUsage: 0 };
|
||||
}
|
||||
@@ -356,6 +360,16 @@ function handleCommand(command: MainProcessCommand): void {
|
||||
// The next collection cycle will pick up the new environments
|
||||
break;
|
||||
|
||||
case 'update_interval':
|
||||
console.log(`[MetricsSubprocess] Updating collection interval to ${command.intervalMs}ms`);
|
||||
COLLECT_INTERVAL = command.intervalMs;
|
||||
// Clear existing interval and restart with new timing
|
||||
if (collectInterval) {
|
||||
clearInterval(collectInterval);
|
||||
collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'shutdown':
|
||||
console.log('[MetricsSubprocess] Shutdown requested');
|
||||
shutdown();
|
||||
@@ -386,8 +400,15 @@ function shutdown(): void {
|
||||
/**
|
||||
* Start the metrics collector
|
||||
*/
|
||||
function start(): void {
|
||||
console.log('[MetricsSubprocess] Starting metrics collection (every 10s)...');
|
||||
async function start(): Promise<void> {
|
||||
// Load interval from settings
|
||||
try {
|
||||
COLLECT_INTERVAL = await getMetricsCollectionInterval();
|
||||
console.log(`[MetricsSubprocess] Starting metrics collection (every ${COLLECT_INTERVAL / 1000}s)...`);
|
||||
} catch (error) {
|
||||
console.error('[MetricsSubprocess] Failed to load interval from settings, using default 30s');
|
||||
COLLECT_INTERVAL = 30000;
|
||||
}
|
||||
|
||||
// Initial collection
|
||||
collectMetrics();
|
||||
|
||||
Reference in new issue
Block a user