v1.0.19
This commit is contained in:
1 parent
4b430340db
commit
9c451aedf9
120 files changed
+7777
-6046
No files matched your search
+99
-62
@@ -2,16 +2,16 @@
|
||||
* Core Authentication Module
|
||||
*
|
||||
* Security features:
|
||||
* - Argon2id password hashing via Bun.password (memory-hard, timing-attack resistant)
|
||||
* - Argon2id password hashing via argon2 (memory-hard, timing-attack resistant)
|
||||
* - Cryptographically secure 32-byte random session tokens
|
||||
* - HttpOnly cookies (prevents XSS from reading tokens)
|
||||
* - Secure flag (HTTPS only in production)
|
||||
* - Secure flag (protocol-aware: x-forwarded-proto or COOKIE_SECURE env var, default off)
|
||||
* - SameSite=Strict (CSRF protection)
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import { secureRandomBytes, usingFallback } from './crypto-fallback';
|
||||
import { argon2id, argon2Verify } from 'hash-wasm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import {
|
||||
getAuthSettings,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from './db';
|
||||
import { Client as LdapClient } from 'ldapts';
|
||||
import { isEnterprise } from './license';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
|
||||
// Session cookie name
|
||||
const SESSION_COOKIE_NAME = 'dockhand_session';
|
||||
@@ -98,42 +99,25 @@ export interface LoginResult {
|
||||
// Password Hashing (Argon2id)
|
||||
// ============================================
|
||||
|
||||
// Argon2id parameters (matching Bun.password defaults)
|
||||
// Argon2id parameters
|
||||
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
|
||||
*
|
||||
* 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
|
||||
* Uses the argon2 npm package (C binding) for native performance.
|
||||
* Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
|
||||
*/
|
||||
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',
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: ARGON2_MEMORY_COST,
|
||||
timeCost: ARGON2_TIME_COST
|
||||
timeCost: ARGON2_TIME_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
hashLength: ARGON2_HASH_LENGTH
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,18 +125,13 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
* 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
|
||||
* The argon2 npm package uses standard PHC format, compatible with existing hashes
|
||||
*/
|
||||
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 await argon2.verify(hash, password);
|
||||
} catch (e) {
|
||||
console.error('[Auth] argon2.verify() threw unexpectedly:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -169,14 +148,43 @@ function generateSessionToken(): string {
|
||||
return secureRandomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to set the Secure flag on the session cookie.
|
||||
*
|
||||
* Priority:
|
||||
* 1. COOKIE_SECURE env var ('true' / 'false') — explicit override
|
||||
* 2. x-forwarded-proto header — set by Traefik / Nginx / Caddy
|
||||
* 3. false — default, matches v1.0.18 Bun behavior
|
||||
*
|
||||
* Defaulting to false (not NODE_ENV) is intentional: Dockhand is commonly
|
||||
* run over plain HTTP in homelabs. Setting Secure unconditionally in production
|
||||
* causes a login loop when there is no HTTPS reverse proxy, because browsers
|
||||
* silently discard Secure cookies on HTTP connections.
|
||||
*
|
||||
* Users behind an HTTPS reverse proxy get Secure cookies automatically via
|
||||
* x-forwarded-proto. Users who terminate TLS in the app itself can opt in
|
||||
* with COOKIE_SECURE=true.
|
||||
*/
|
||||
function isSecureContext(request?: Request): boolean {
|
||||
if (process.env.COOKIE_SECURE === 'false') return false;
|
||||
if (process.env.COOKIE_SECURE === 'true') return true;
|
||||
if (request) {
|
||||
const proto = request.headers.get('x-forwarded-proto');
|
||||
if (proto === 'https') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
* @param provider - Auth provider: 'local', or provider name like 'Keycloak', 'Azure AD', etc.
|
||||
* @param request - Optional incoming request (used to detect HTTPS via x-forwarded-proto)
|
||||
*/
|
||||
export async function createUserSession(
|
||||
userId: number,
|
||||
provider: string,
|
||||
cookies: Cookies
|
||||
cookies: Cookies,
|
||||
request?: Request
|
||||
): Promise<Session> {
|
||||
// Clean up expired sessions periodically
|
||||
await deleteExpiredSessions();
|
||||
@@ -198,7 +206,7 @@ export async function createUserSession(
|
||||
const session = await dbCreateSession(sessionId, userId, provider, expiresAt);
|
||||
|
||||
// Set secure cookie
|
||||
setSessionCookie(cookies, sessionId, sessionTimeout);
|
||||
setSessionCookie(cookies, sessionId, sessionTimeout, request);
|
||||
|
||||
// Update user's last login time
|
||||
await updateUser(userId, { lastLogin: new Date().toISOString() });
|
||||
@@ -209,11 +217,11 @@ export async function createUserSession(
|
||||
/**
|
||||
* Set the session cookie with secure attributes
|
||||
*/
|
||||
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number): void {
|
||||
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, request?: Request): void {
|
||||
cookies.set(SESSION_COOKIE_NAME, sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true, // Prevents XSS attacks from reading cookie
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
|
||||
sameSite: 'strict', // CSRF protection
|
||||
maxAge: maxAge // Session timeout in seconds
|
||||
});
|
||||
@@ -563,6 +571,19 @@ export async function authenticateLdap(
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in an LDAP filter value (RFC 4515).
|
||||
* Prevents LDAP injection via wildcards or control characters.
|
||||
*/
|
||||
function escapeLdapFilterValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\0/g, '\\00');
|
||||
}
|
||||
|
||||
/**
|
||||
* Try authentication against a specific LDAP configuration
|
||||
*/
|
||||
@@ -585,8 +606,10 @@ async function tryLdapAuth(
|
||||
await client.bind(config.bindDn, config.bindPassword);
|
||||
}
|
||||
|
||||
// Search for the user
|
||||
const filter = config.userFilter.replace('{{username}}', username);
|
||||
// Escape the username before interpolating into the LDAP filter (RFC 4515)
|
||||
// to prevent LDAP injection via wildcards or special characters.
|
||||
const safeUsername = escapeLdapFilterValue(username);
|
||||
const filter = config.userFilter.replace('{{username}}', safeUsername);
|
||||
const { searchEntries } = await client.search(config.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: filter,
|
||||
@@ -599,9 +622,11 @@ async function tryLdapAuth(
|
||||
]
|
||||
});
|
||||
|
||||
// Use a single generic error for both "not found" and "wrong password"
|
||||
// to avoid leaking whether a username exists via response content or timing.
|
||||
if (searchEntries.length === 0) {
|
||||
await client.unbind();
|
||||
return { success: false, error: 'User not found' };
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
const userEntry = searchEntries[0];
|
||||
@@ -666,11 +691,11 @@ async function tryLdapAuth(
|
||||
if (adminRole) {
|
||||
const hasAdminRole = await userHasAdminRole(user.id);
|
||||
if (shouldBeAdmin && !hasAdminRole) {
|
||||
// Assign Admin role
|
||||
await assignUserRole(user.id, adminRole.id, null);
|
||||
} else if (!shouldBeAdmin && hasAdminRole && config.adminGroup) {
|
||||
// Remove Admin role if user is no longer in admin group
|
||||
await removeUserRole(user.id, adminRole.id);
|
||||
}
|
||||
// Note: We don't remove Admin role if not in LDAP group anymore
|
||||
// to prevent accidental lockouts (same behavior as before)
|
||||
}
|
||||
|
||||
// Process role mappings (Enterprise feature)
|
||||
@@ -683,14 +708,29 @@ async function tryLdapAuth(
|
||||
const userExistingRoles = await getUserRoles(user.id);
|
||||
const existingRoleIds = new Set(userExistingRoles.map(r => r.roleId));
|
||||
|
||||
for (const mapping of roleMappings) {
|
||||
// Skip if user already has this role
|
||||
if (existingRoleIds.has(mapping.roleId)) continue;
|
||||
// All role IDs referenced in mappings (these are LDAP-managed)
|
||||
const mappedRoleIds = new Set(roleMappings.map(m => m.roleId));
|
||||
|
||||
// Check if user is a member of the LDAP group
|
||||
// Determine which mapped roles user should have based on current group membership
|
||||
const shouldHaveRoleIds = new Set<number>();
|
||||
for (const mapping of roleMappings) {
|
||||
const isInGroup = await checkLdapGroupMembership(config, userDn, mapping.groupDn);
|
||||
if (isInGroup) {
|
||||
await assignUserRole(user.id, mapping.roleId, undefined);
|
||||
shouldHaveRoleIds.add(mapping.roleId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add roles user should have but doesn't
|
||||
for (const roleId of shouldHaveRoleIds) {
|
||||
if (!existingRoleIds.has(roleId)) {
|
||||
await assignUserRole(user.id, roleId, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove mapped roles user has but shouldn't
|
||||
for (const roleId of mappedRoleIds) {
|
||||
if (existingRoleIds.has(roleId) && !shouldHaveRoleIds.has(roleId)) {
|
||||
await removeUserRole(user.id, roleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -744,8 +784,9 @@ async function checkLdapGroupMembership(
|
||||
let groupFilter: string;
|
||||
|
||||
if (config.groupFilter) {
|
||||
// User provided custom filter
|
||||
searchBase = config.groupBaseDn || groupDnOrName;
|
||||
// User provided custom filter - use group DN directly when it's a full DN
|
||||
// to avoid searching all groups under groupBaseDn
|
||||
searchBase = isFullDn ? groupDnOrName : (config.groupBaseDn || groupDnOrName);
|
||||
groupFilter = config.groupFilter
|
||||
.replace('{{username}}', userDn)
|
||||
.replace('{{user_dn}}', userDn)
|
||||
@@ -765,7 +806,7 @@ async function checkLdapGroupMembership(
|
||||
}
|
||||
|
||||
const { searchEntries } = await client.search(searchBase, {
|
||||
scope: isFullDn && !config.groupFilter ? 'base' : 'sub',
|
||||
scope: isFullDn ? 'base' : 'sub',
|
||||
filter: groupFilter,
|
||||
sizeLimit: 1
|
||||
});
|
||||
@@ -825,9 +866,7 @@ function generateBackupCodes(): string[] {
|
||||
async function hashBackupCode(code: string): Promise<string> {
|
||||
// Normalize: uppercase, remove spaces and dashes
|
||||
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(normalized);
|
||||
return hasher.digest('hex');
|
||||
return createHash('sha256').update(normalized).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1172,9 +1211,7 @@ async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocumen
|
||||
*/
|
||||
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
|
||||
const codeVerifier = secureRandomBytes(32).toString('base64url');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(codeVerifier);
|
||||
const codeChallenge = hasher.digest('base64url') as string;
|
||||
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Crypto Fallback for Old Linux Kernels
|
||||
*
|
||||
* The getrandom() syscall was added in Linux 3.17. On older kernels (like 3.10.x),
|
||||
* Bun's built-in crypto functions will fail with "getrandom() failed to provide entropy".
|
||||
* Node.js crypto functions may fail with "getrandom() failed to provide entropy".
|
||||
*
|
||||
* This module provides fallback implementations that read from /dev/urandom directly
|
||||
* when running on kernels older than 3.17.
|
||||
|
||||
+34
-40
@@ -7,7 +7,6 @@
|
||||
|
||||
import {
|
||||
db,
|
||||
rawClient,
|
||||
isPostgres,
|
||||
isSqlite,
|
||||
eq,
|
||||
@@ -200,6 +199,10 @@ export async function deleteEnvironment(id: number): Promise<boolean> {
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) return false;
|
||||
|
||||
// Clean up in-memory metrics
|
||||
const { clearEnvironmentMetrics } = await import('./metrics-store.js');
|
||||
clearEnvironmentMetrics(id);
|
||||
|
||||
// Clean up related records that don't have cascade delete defined
|
||||
try {
|
||||
await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id));
|
||||
@@ -576,45 +579,28 @@ export async function saveHostMetric(
|
||||
memoryPercent: number,
|
||||
memoryUsed: number,
|
||||
memoryTotal: number,
|
||||
environmentId?: number
|
||||
environmentId?: number,
|
||||
_skipEnvCheck = false
|
||||
): Promise<void> {
|
||||
// Verify environment exists before inserting (avoids FK violations on deleted envs)
|
||||
if (environmentId) {
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) return;
|
||||
}
|
||||
|
||||
await db.insert(hostMetrics).values({
|
||||
environmentId: environmentId || null,
|
||||
cpuPercent,
|
||||
memoryPercent,
|
||||
memoryUsed,
|
||||
memoryTotal
|
||||
});
|
||||
|
||||
// Cleanup old metrics (keep last 24 hours)
|
||||
const cutoff24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
await db.delete(hostMetrics).where(sql`timestamp < ${cutoff24h}`);
|
||||
// Delegated to in-memory ring buffer (no DB writes)
|
||||
if (!environmentId) return;
|
||||
const { pushMetric } = await import('./metrics-store.js');
|
||||
pushMetric(environmentId, cpuPercent, memoryPercent, memoryUsed, memoryTotal);
|
||||
}
|
||||
|
||||
export async function getHostMetrics(limit = 60, environmentId?: number): Promise<HostMetric[]> {
|
||||
if (environmentId) {
|
||||
return db.select().from(hostMetrics)
|
||||
.where(eq(hostMetrics.environmentId, environmentId))
|
||||
.orderBy(desc(hostMetrics.timestamp))
|
||||
.limit(limit);
|
||||
const { getMetricsHistory } = await import('./metrics-store.js');
|
||||
// getMetricsHistory returns oldest-first, but callers expect newest-first
|
||||
return getMetricsHistory(environmentId, limit).reverse();
|
||||
}
|
||||
return db.select().from(hostMetrics)
|
||||
.orderBy(desc(hostMetrics.timestamp))
|
||||
.limit(limit);
|
||||
const { getAllMetrics } = await import('./metrics-store.js');
|
||||
return getAllMetrics(limit);
|
||||
}
|
||||
|
||||
export async function getLatestHostMetrics(environmentId: number): Promise<HostMetric | null> {
|
||||
const results = await db.select().from(hostMetrics)
|
||||
.where(eq(hostMetrics.environmentId, environmentId))
|
||||
.orderBy(desc(hostMetrics.timestamp))
|
||||
.limit(1);
|
||||
return results[0] ?? null;
|
||||
const { getLatestMetric } = await import('./metrics-store.js');
|
||||
return getLatestMetric(environmentId);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -3269,20 +3255,23 @@ export interface ContainerEventResult {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export async function logContainerEvent(data: ContainerEventCreateData): Promise<ContainerEventData> {
|
||||
// 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({
|
||||
export async function logContainerEvent(
|
||||
data: ContainerEventCreateData
|
||||
): Promise<ContainerEventData> {
|
||||
const attrs = data.actorAttributes ? JSON.stringify(data.actorAttributes) : null;
|
||||
|
||||
const [inserted] = await db.insert(containerEvents).values({
|
||||
environmentId: data.environmentId ?? null,
|
||||
containerId: data.containerId,
|
||||
containerName: data.containerName ?? null,
|
||||
image: data.image ?? null,
|
||||
action: data.action,
|
||||
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
|
||||
actorAttributes: attrs,
|
||||
timestamp: data.timestamp
|
||||
}).returning();
|
||||
}).returning({ id: containerEvents.id });
|
||||
|
||||
return getContainerEvent(result[0].id) as Promise<ContainerEventData>;
|
||||
const event = await getContainerEvent(inserted.id);
|
||||
return event!;
|
||||
}
|
||||
|
||||
export async function getContainerEvent(id: number): Promise<ContainerEventData | undefined> {
|
||||
@@ -4502,11 +4491,16 @@ export async function setStackEnvVars(
|
||||
));
|
||||
}
|
||||
|
||||
// Insert new vars
|
||||
// Insert new vars (deduplicate by key - last entry wins)
|
||||
if (variables.length > 0) {
|
||||
const seen = new Map<string, { key: string; value: string; isSecret?: boolean }>();
|
||||
for (const v of variables) {
|
||||
seen.set(v.key, v);
|
||||
}
|
||||
const deduped = Array.from(seen.values());
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(stackEnvironmentVariables).values(
|
||||
variables.map(v => ({
|
||||
deduped.map(v => ({
|
||||
stackName,
|
||||
environmentId,
|
||||
key: v.key,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Database Connection Module
|
||||
*
|
||||
* Provides a unified database connection using Bun's SQL API.
|
||||
* Supports both SQLite (default) and PostgreSQL (via DATABASE_URL).
|
||||
*/
|
||||
|
||||
import { SQL } from 'bun';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Database configuration
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
|
||||
// Detect database type
|
||||
export const isPostgres = databaseUrl && (databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://'));
|
||||
export const isSqlite = !isPostgres;
|
||||
|
||||
/**
|
||||
* Read a SQL file from the appropriate sql directory.
|
||||
*/
|
||||
function readSql(filename: string): string {
|
||||
const sqlDir = isPostgres ? 'postgres' : 'sqlite';
|
||||
return readFileSync(join(__dirname, sqlDir, 'sql', filename), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PostgreSQL connection URL format.
|
||||
*/
|
||||
function validatePostgresUrl(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
|
||||
exitWithError(`Invalid protocol "${parsed.protocol}". Expected "postgres:" or "postgresql:"`, url);
|
||||
}
|
||||
|
||||
if (!parsed.hostname) {
|
||||
exitWithError('Missing hostname in DATABASE_URL', url);
|
||||
}
|
||||
|
||||
if (!parsed.pathname || parsed.pathname === '/') {
|
||||
exitWithError('Missing database name in DATABASE_URL', url);
|
||||
}
|
||||
} catch {
|
||||
exitWithError('Invalid URL format', url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print connection error and exit.
|
||||
*/
|
||||
function exitWithError(error: string, url?: string): never {
|
||||
console.error('\n' + '='.repeat(70));
|
||||
console.error('DATABASE CONNECTION ERROR');
|
||||
console.error('='.repeat(70));
|
||||
console.error(`\nError: ${error}`);
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.password) parsed.password = '***';
|
||||
console.error(`\nProvided URL: ${parsed.toString()}`);
|
||||
} catch {
|
||||
console.error(`\nProvided URL: ${url.replace(/:[^:@]+@/, ':***@')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('\n' + '-'.repeat(70));
|
||||
console.error('DATABASE_URL format:');
|
||||
console.error('-'.repeat(70));
|
||||
console.error('\n postgres://USER:PASSWORD@HOST:PORT/DATABASE');
|
||||
console.error('\nExamples:');
|
||||
console.error(' postgres://dockhand:secret@localhost:5432/dockhand');
|
||||
console.error(' postgres://admin:p4ssw0rd@192.168.1.100:5432/dockhand');
|
||||
console.error(' postgresql://user:pass@db.example.com/mydb?sslmode=require');
|
||||
console.error('\n' + '-'.repeat(70));
|
||||
console.error('To use SQLite instead, remove the DATABASE_URL environment variable.');
|
||||
console.error('='.repeat(70) + '\n');
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the database connection.
|
||||
*/
|
||||
function createConnection(): SQL {
|
||||
if (isPostgres) {
|
||||
// Validate PostgreSQL URL
|
||||
validatePostgresUrl(databaseUrl!);
|
||||
|
||||
console.log('Connecting to PostgreSQL database...');
|
||||
try {
|
||||
const sql = new SQL(databaseUrl!);
|
||||
return sql;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
exitWithError(`Failed to connect to PostgreSQL: ${message}`, databaseUrl);
|
||||
}
|
||||
} else {
|
||||
// SQLite: Ensure db directory exists
|
||||
const dbDir = join(dataDir, 'db');
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = join(dbDir, 'dockhand.db');
|
||||
console.log(`Using SQLite database at: ${dbPath}`);
|
||||
|
||||
const sql = new SQL(`sqlite://${dbPath}`);
|
||||
|
||||
// Enable WAL mode for better performance
|
||||
sql.run('PRAGMA journal_mode = WAL');
|
||||
|
||||
return sql;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database schema.
|
||||
*/
|
||||
async function initializeSchema(sql: SQL): Promise<void> {
|
||||
try {
|
||||
// Create schema (tables)
|
||||
await sql.run(readSql('schema.sql'));
|
||||
|
||||
// Create indexes
|
||||
await sql.run(readSql('indexes.sql'));
|
||||
|
||||
// Insert seed data
|
||||
await sql.run(readSql('seed.sql'));
|
||||
|
||||
// Update system roles
|
||||
await sql.run(readSql('system-roles.sql'));
|
||||
|
||||
// Run maintenance
|
||||
await sql.run(readSql('maintenance.sql'));
|
||||
|
||||
console.log(`Database initialized successfully (${isPostgres ? 'PostgreSQL' : 'SQLite'})`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to initialize database schema:', message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the database connection
|
||||
export const sql = createConnection();
|
||||
|
||||
// Initialize schema (runs async but we handle it)
|
||||
initializeSchema(sql).catch((error) => {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[DB] Database initialization failed:', errorMsg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to convert SQLite integer booleans to JS booleans.
|
||||
* PostgreSQL returns actual booleans, SQLite returns 0/1.
|
||||
*/
|
||||
export function toBool(value: any): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert JS boolean to database value.
|
||||
* PostgreSQL uses boolean, SQLite uses 0/1.
|
||||
*/
|
||||
export function fromBool(value: boolean): boolean | number {
|
||||
return isPostgres ? value : (value ? 1 : 0);
|
||||
}
|
||||
@@ -208,11 +208,11 @@ function readMigrationJournal(migrationsFolder: string): MigrationJournal | null
|
||||
async function getAppliedMigrations(client: any, postgres: boolean): Promise<AppliedMigration[]> {
|
||||
try {
|
||||
if (postgres) {
|
||||
// PostgreSQL using Bun.sql - note the 'drizzle' schema
|
||||
// PostgreSQL using postgres-js - note the 'drizzle' schema
|
||||
const result = await client`SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id`;
|
||||
return result.map((r: any) => ({ hash: r.hash, createdAt: r.created_at }));
|
||||
} else {
|
||||
// SQLite using bun:sqlite
|
||||
// SQLite using better-sqlite3
|
||||
const stmt = client.prepare('SELECT hash, created_at FROM __drizzle_migrations ORDER BY id');
|
||||
return stmt.all().map((r: any) => ({ hash: r.hash, createdAt: r.created_at }));
|
||||
}
|
||||
@@ -484,10 +484,10 @@ async function runMigrations(
|
||||
// Run migrations
|
||||
try {
|
||||
if (postgres) {
|
||||
const { migrate } = await import('drizzle-orm/bun-sql/migrator');
|
||||
const { migrate } = await import('drizzle-orm/postgres-js/migrator');
|
||||
await migrate(database, { migrationsFolder });
|
||||
} else {
|
||||
const { migrate } = await import('drizzle-orm/bun-sqlite/migrator');
|
||||
const { migrate } = await import('drizzle-orm/better-sqlite3/migrator');
|
||||
await migrate(database, { migrationsFolder });
|
||||
}
|
||||
|
||||
@@ -605,7 +605,7 @@ async function initializeDatabase() {
|
||||
logHeader('DATABASE INITIALIZATION');
|
||||
|
||||
if (isPostgres) {
|
||||
// PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
|
||||
// PostgreSQL via postgres-js
|
||||
validatePostgresUrl(config.databaseUrl!);
|
||||
|
||||
logInfo(`Database: PostgreSQL`);
|
||||
@@ -634,7 +634,7 @@ async function initializeDatabase() {
|
||||
handleMigrationFailure(result.error, true);
|
||||
}
|
||||
} else {
|
||||
// SQLite via bun:sqlite
|
||||
// SQLite via better-sqlite3
|
||||
const dbDir = join(config.dataDir, 'db');
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
@@ -645,8 +645,8 @@ async function initializeDatabase() {
|
||||
logInfo(`Database: SQLite`);
|
||||
logInfo(`Path: ${dbPath}`);
|
||||
|
||||
const { drizzle } = await import('drizzle-orm/bun-sqlite');
|
||||
const { Database } = await import('bun:sqlite');
|
||||
const { drizzle } = await import('drizzle-orm/better-sqlite3');
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
|
||||
// Import SQLite schema
|
||||
schema = await import('./schema/index.js');
|
||||
@@ -655,11 +655,11 @@ async function initializeDatabase() {
|
||||
rawClient = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better performance and concurrency
|
||||
rawClient.exec('PRAGMA journal_mode = WAL');
|
||||
rawClient.pragma('journal_mode = WAL');
|
||||
// Synchronous NORMAL is a good balance between safety and speed
|
||||
rawClient.exec('PRAGMA synchronous = NORMAL');
|
||||
rawClient.pragma('synchronous = NORMAL');
|
||||
// Increase busy timeout to handle concurrent access (5 seconds)
|
||||
rawClient.exec('PRAGMA busy_timeout = 5000');
|
||||
rawClient.pragma('busy_timeout = 5000');
|
||||
|
||||
db = drizzle({ client: rawClient, schema });
|
||||
logSuccess('SQLite database opened');
|
||||
|
||||
+638
-157
File diff suppressed because it is too large.
Load diff
@@ -2,17 +2,25 @@
|
||||
* Container Event Emitter
|
||||
*
|
||||
* Shared EventEmitter for broadcasting container events to SSE clients.
|
||||
* Events are emitted by the subprocess-manager when it receives them from the event-subprocess.
|
||||
* Events are emitted by the collection worker when processing Docker events.
|
||||
*
|
||||
* IMPORTANT: Uses globalThis to ensure a single instance across all module imports.
|
||||
* In Vite dev mode and SvelteKit production builds, server modules can be loaded
|
||||
* multiple times (HMR, chunking), creating separate EventEmitter instances.
|
||||
* Using globalThis guarantees emitters and listeners share the same object.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Event emitter for broadcasting new events to SSE clients
|
||||
// Used by:
|
||||
// - subprocess-manager.ts: emits events received from event-subprocess via IPC
|
||||
// - api/activity/events/+server.ts: listens for events to broadcast via SSE
|
||||
export const containerEventEmitter = new EventEmitter();
|
||||
const GLOBAL_KEY = '__dockhand_container_event_emitter__';
|
||||
|
||||
// Allow up to 100 concurrent SSE listeners (default is 10)
|
||||
// This prevents MaxListenersExceededWarning with many dashboard clients
|
||||
containerEventEmitter.setMaxListeners(100);
|
||||
// Ensure single instance via globalThis
|
||||
if (!(globalThis as any)[GLOBAL_KEY]) {
|
||||
const emitter = new EventEmitter();
|
||||
// Allow up to 100 concurrent SSE listeners (default is 10)
|
||||
// This prevents MaxListenersExceededWarning with many dashboard clients
|
||||
emitter.setMaxListeners(100);
|
||||
(globalThis as any)[GLOBAL_KEY] = emitter;
|
||||
}
|
||||
|
||||
export const containerEventEmitter: EventEmitter = (globalThis as any)[GLOBAL_KEY];
|
||||
+67
-50
@@ -1,5 +1,7 @@
|
||||
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, chmodSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename, relative } from 'node:path';
|
||||
import { spawn as nodeSpawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
getGitRepository,
|
||||
getGitCredential,
|
||||
@@ -14,6 +16,26 @@ import {
|
||||
} from './db';
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
|
||||
/**
|
||||
* Collect stdout, stderr and exit code from a spawned process.
|
||||
*/
|
||||
function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||
stderr: Buffer.concat(stderrChunks).toString()
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Directory for storing cloned repositories
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos'));
|
||||
@@ -79,7 +101,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
if (uid === undefined || uid === 0) return; // root or not available
|
||||
|
||||
try {
|
||||
const passwd = await Bun.file('/etc/passwd').text();
|
||||
const passwd = readFileSync('/etc/passwd', 'utf-8');
|
||||
const uidStr = `:${uid}:`;
|
||||
if (passwd.split('\n').some(line => {
|
||||
const parts = line.split(':');
|
||||
@@ -100,17 +122,17 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
// Create temp passwd/group with the missing entry
|
||||
try {
|
||||
const gid = process.getgid?.() ?? uid;
|
||||
const passwd = await Bun.file('/etc/passwd').text();
|
||||
const group = await Bun.file('/etc/group').text();
|
||||
const passwd = readFileSync('/etc/passwd', 'utf-8');
|
||||
const group = readFileSync('/etc/group', 'utf-8');
|
||||
|
||||
const passwdEntry = `dockhand:x:${uid}:${gid}:Dockhand:/home/dockhand:/bin/sh`;
|
||||
await Bun.write(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n');
|
||||
writeFileSync(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n');
|
||||
|
||||
const gidExists = group.split('\n').some(line => line.split(':')[2] === String(gid));
|
||||
if (gidExists) {
|
||||
await Bun.write(TMP_GROUP, group);
|
||||
writeFileSync(TMP_GROUP, group);
|
||||
} else {
|
||||
await Bun.write(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`);
|
||||
writeFileSync(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`);
|
||||
}
|
||||
|
||||
_nssWrapperNeeded = true;
|
||||
@@ -135,8 +157,10 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
await ensurePasswdEntry(env);
|
||||
|
||||
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
|
||||
// Create a temporary SSH key file (use absolute path so SSH can find it)
|
||||
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
|
||||
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
|
||||
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
|
||||
// SSH refuses keys that are accessible by others. /tmp is always a proper filesystem.
|
||||
const sshKeyPath = `/tmp/.ssh-key-${credential.id}`;
|
||||
|
||||
// Ensure SSH key ends with a newline (newer SSH versions are strict about this)
|
||||
let keyContent = credential.sshPrivateKey;
|
||||
@@ -144,18 +168,19 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
keyContent += '\n';
|
||||
}
|
||||
|
||||
await Bun.write(sshKeyPath, keyContent);
|
||||
writeFileSync(sshKeyPath, keyContent);
|
||||
// Ensure SSH key has correct permissions (0600 = owner read/write only)
|
||||
// Bun.write's mode option doesn't always work reliably, so use chmodSync
|
||||
// writeFileSync's mode option doesn't always work reliably, so use chmodSync
|
||||
chmodSync(sshKeyPath, 0o600);
|
||||
|
||||
// If key has a passphrase, decrypt it in-place so SSH can use it non-interactively
|
||||
if (credential.sshPassphrase) {
|
||||
const result = Bun.spawnSync([
|
||||
'ssh-keygen', '-p', '-f', sshKeyPath,
|
||||
'-P', credential.sshPassphrase, '-N', ''
|
||||
], { env, stderr: 'pipe' });
|
||||
if (result.exitCode !== 0) {
|
||||
const result = spawnSync(
|
||||
'ssh-keygen',
|
||||
['-p', '-f', sshKeyPath, '-P', credential.sshPassphrase, '-N', ''],
|
||||
{ env, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr.toString().trim();
|
||||
console.warn(`[git] Failed to decrypt SSH key: ${stderr}`);
|
||||
}
|
||||
@@ -173,7 +198,7 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
|
||||
function cleanupSshKey(credential: GitCredential | null): void {
|
||||
if (credential?.authType === 'ssh') {
|
||||
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
|
||||
const sshKeyPath = `/tmp/.ssh-key-${credential.id}`;
|
||||
try {
|
||||
if (existsSync(sshKeyPath)) {
|
||||
rmSync(sshKeyPath);
|
||||
@@ -207,21 +232,15 @@ function buildRepoUrl(url: string, credential: GitCredential | null): string {
|
||||
|
||||
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
try {
|
||||
const proc = Bun.spawn(['git', ...args], {
|
||||
const proc = nodeSpawn('git', args, {
|
||||
cwd,
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text()
|
||||
]);
|
||||
const result = await collectProcess(proc);
|
||||
|
||||
const code = await proc.exited;
|
||||
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), code };
|
||||
return { stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.exitCode };
|
||||
} catch (err: any) {
|
||||
return { stdout: '', stderr: err.message, code: 1 };
|
||||
}
|
||||
@@ -350,8 +369,6 @@ async function testRepositoryConnection(options: {
|
||||
env
|
||||
);
|
||||
|
||||
cleanupSshKey(credential);
|
||||
|
||||
if (result.code !== 0) {
|
||||
console.error('[Git] Connection test failed:', result.stderr);
|
||||
return { success: false, error: cleanGitError(result.stderr) };
|
||||
@@ -366,7 +383,6 @@ async function testRepositoryConnection(options: {
|
||||
process.cwd(),
|
||||
env
|
||||
);
|
||||
cleanupSshKey(credential);
|
||||
|
||||
if (allBranchesResult.code !== 0) {
|
||||
return { success: false, error: cleanGitError(allBranchesResult.stderr) };
|
||||
@@ -400,8 +416,9 @@ async function testRepositoryConnection(options: {
|
||||
lastCommit
|
||||
};
|
||||
} catch (error: any) {
|
||||
cleanupSshKey(credential);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
cleanupSshKey(credential);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +536,7 @@ export async function syncRepository(repoId: number): Promise<SyncResult> {
|
||||
throw new Error(`Compose file not found: ${repo.composePath}`);
|
||||
}
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
|
||||
// Update repository status
|
||||
await updateGitRepository(repoId, {
|
||||
@@ -798,7 +815,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
throw new Error(`Compose file not found: ${gitStack.composePath}`);
|
||||
}
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
|
||||
console.log(`${logPrefix} Compose content:`);
|
||||
console.log(composeContent);
|
||||
@@ -819,7 +836,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
console.log(`${logPrefix} Reading env file...`);
|
||||
envFileContent = await Bun.file(envFilePath).text();
|
||||
envFileContent = readFileSync(envFilePath, 'utf-8');
|
||||
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
|
||||
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
|
||||
|
||||
@@ -1142,7 +1159,7 @@ export async function deployGitStackWithProgress(
|
||||
throw new Error(`Compose file not found: ${gitStack.composePath}`);
|
||||
}
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
|
||||
// Determine the compose directory (for copying all files)
|
||||
const composeDir = dirname(composePath);
|
||||
@@ -1153,7 +1170,7 @@ export async function deployGitStackWithProgress(
|
||||
const envFilePath = join(repoPath, gitStack.envFilePath);
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
const envContent = await Bun.file(envFilePath).text();
|
||||
const envContent = readFileSync(envFilePath, 'utf-8');
|
||||
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
|
||||
} catch (err) {
|
||||
// Log but don't fail - env file is optional
|
||||
@@ -1251,12 +1268,11 @@ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: st
|
||||
const maxDepth = 3;
|
||||
|
||||
// Use find to locate all .env* files
|
||||
const proc = Bun.spawn(['find', repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
const proc = nodeSpawn('find', [repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const findResult = await collectProcess(proc);
|
||||
const output = findResult.stdout;
|
||||
|
||||
const files = output.trim().split('\n').filter(f => f);
|
||||
const envFiles: string[] = [];
|
||||
@@ -1372,7 +1388,7 @@ export async function readGitStackEnvFile(
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(fullPath).text();
|
||||
const content = readFileSync(fullPath, 'utf-8');
|
||||
const vars = parseEnvFileContent(content);
|
||||
return { vars };
|
||||
} catch (error: any) {
|
||||
@@ -1426,17 +1442,18 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
|
||||
const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);
|
||||
|
||||
// Clone with depth 1 (shallow clone for speed)
|
||||
const cloneProc = Bun.spawn(
|
||||
['git', 'clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
|
||||
const cloneProc = nodeSpawn(
|
||||
'git',
|
||||
['clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
|
||||
{
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env
|
||||
}
|
||||
);
|
||||
|
||||
const cloneStderr = await new Response(cloneProc.stderr).text();
|
||||
const cloneExitCode = await cloneProc.exited;
|
||||
const cloneResult = await collectProcess(cloneProc);
|
||||
const cloneStderr = cloneResult.stderr;
|
||||
const cloneExitCode = cloneResult.exitCode;
|
||||
|
||||
if (cloneExitCode !== 0) {
|
||||
console.error(`${logPrefix} Clone failed:`, cloneStderr);
|
||||
@@ -1455,7 +1472,7 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
|
||||
// Read base .env file if it exists
|
||||
if (existsSync(baseEnvPath)) {
|
||||
console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`);
|
||||
const content = await Bun.file(baseEnvPath).text();
|
||||
const content = readFileSync(baseEnvPath, 'utf-8');
|
||||
const baseVars = parseEnvFileContent(content, 'preview');
|
||||
for (const [key, value] of Object.entries(baseVars)) {
|
||||
vars[key] = value;
|
||||
@@ -1471,7 +1488,7 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
|
||||
const additionalEnvPath = join(tempDir, envFilePath);
|
||||
if (existsSync(additionalEnvPath)) {
|
||||
console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`);
|
||||
const content = await Bun.file(additionalEnvPath).text();
|
||||
const content = readFileSync(additionalEnvPath, 'utf-8');
|
||||
const additionalVars = parseEnvFileContent(content, 'preview');
|
||||
for (const [key, value] of Object.entries(additionalVars)) {
|
||||
vars[key] = value;
|
||||
|
||||
+315
-19
@@ -5,10 +5,11 @@
|
||||
* Handles request/response correlation, heartbeat tracking, and metrics collection.
|
||||
*/
|
||||
|
||||
import { db, hawserTokens, environments, eq } from './db/drizzle.js';
|
||||
import { logContainerEvent, saveHostMetric, type ContainerEventAction } from './db.js';
|
||||
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
||||
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
|
||||
@@ -40,7 +41,7 @@ export interface EdgeConnection {
|
||||
hostname: string;
|
||||
capabilities: string[];
|
||||
connectedAt: Date;
|
||||
lastHeartbeat: Date;
|
||||
lastHeartbeat: number;
|
||||
pendingRequests: Map<string, PendingRequest>;
|
||||
pendingStreamRequests: Map<string, PendingStreamRequest>;
|
||||
lastMetrics?: {
|
||||
@@ -76,6 +77,9 @@ declare global {
|
||||
var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined;
|
||||
var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventMessage['event']) => Promise<void>) | undefined;
|
||||
var __hawserHandleMetrics: ((envId: number, metrics: MetricsMessage['metrics']) => Promise<void>) | undefined;
|
||||
var __hawserHandleMessage: ((ws: any, msg: any, connId: string) => Promise<void>) | undefined;
|
||||
var __hawserHandleDisconnect: ((ws: any, connId: string) => void) | undefined;
|
||||
var __terminalHandleExecMessage: ((msg: any) => void) | undefined;
|
||||
}
|
||||
export const edgeConnections: Map<number, EdgeConnection> =
|
||||
globalThis.__hawserEdgeConnections ?? (globalThis.__hawserEdgeConnections = new Map());
|
||||
@@ -94,7 +98,7 @@ export function initializeEdgeManager(): void {
|
||||
const timeout = 90 * 1000; // 90 seconds (3 missed heartbeats)
|
||||
|
||||
for (const [envId, conn] of edgeConnections) {
|
||||
if (now - conn.lastHeartbeat.getTime() > timeout) {
|
||||
if (now - conn.lastHeartbeat > timeout) {
|
||||
const pendingCount = conn.pendingRequests.size;
|
||||
const streamCount = conn.pendingStreamRequests.size;
|
||||
console.log(
|
||||
@@ -120,6 +124,21 @@ export function initializeEdgeManager(): void {
|
||||
updateEnvironmentStatus(envId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain reconnection tracker: reset for stable connections, prune stale entries
|
||||
for (const [envId, tracker] of reconnectTracker) {
|
||||
const conn = edgeConnections.get(envId);
|
||||
if (conn && now - conn.lastHeartbeat < STABLE_THRESHOLD_MS) {
|
||||
// Connection is stable — reset tracker so next reconnect is unthrottled
|
||||
reconnectTracker.delete(envId);
|
||||
} else if (!conn && tracker.timestamps.length > 0) {
|
||||
const lastAttempt = tracker.timestamps[tracker.timestamps.length - 1];
|
||||
if (now - lastAttempt > STALE_TRACKER_MS) {
|
||||
// No connection and no recent attempts — clean up
|
||||
reconnectTracker.delete(envId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
@@ -137,6 +156,7 @@ export function stopEdgeManager(): void {
|
||||
conn.ws.close(1001, 'Server shutdown');
|
||||
}
|
||||
edgeConnections.clear();
|
||||
reconnectTracker.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +209,7 @@ export async function handleEdgeContainerEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// Register global handler for patch-build.ts to use
|
||||
// Register global handler for server.js to use
|
||||
globalThis.__hawserHandleContainerEvent = handleEdgeContainerEvent;
|
||||
|
||||
/**
|
||||
@@ -218,14 +238,8 @@ export async function handleEdgeMetrics(
|
||||
? (metrics.memoryUsed / metrics.memoryTotal) * 100
|
||||
: 0;
|
||||
|
||||
// Save to database using the existing function
|
||||
await saveHostMetric(
|
||||
cpuPercent,
|
||||
memoryPercent,
|
||||
metrics.memoryUsed,
|
||||
metrics.memoryTotal,
|
||||
environmentId
|
||||
);
|
||||
// Push to in-memory ring buffer
|
||||
pushMetric(environmentId, cpuPercent, memoryPercent, metrics.memoryUsed, metrics.memoryTotal);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Hawser] Error saving metrics:', errorMsg);
|
||||
@@ -288,6 +302,13 @@ export async function generateHawserToken(
|
||||
edgeConnections.delete(environmentId);
|
||||
}
|
||||
|
||||
// Revoke all existing active tokens for this environment so the old agent
|
||||
// can no longer reconnect and fight with the new one over the connection slot
|
||||
await db
|
||||
.update(hawserTokens)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(hawserTokens.environmentId, environmentId), eq(hawserTokens.isActive, true)));
|
||||
|
||||
// Use provided token or generate a new one
|
||||
let token: string;
|
||||
if (rawToken) {
|
||||
@@ -396,6 +417,7 @@ export function handleEdgeConnection(
|
||||
// Reject all pending requests before closing
|
||||
for (const [requestId, pending] of existing.pendingRequests) {
|
||||
console.log(`[Hawser] Rejecting pending request ${requestId} due to connection replacement`);
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Connection replaced by new agent'));
|
||||
}
|
||||
for (const [requestId, pending] of existing.pendingStreamRequests) {
|
||||
@@ -405,7 +427,12 @@ export function handleEdgeConnection(
|
||||
existing.pendingRequests.clear();
|
||||
existing.pendingStreamRequests.clear();
|
||||
|
||||
existing.ws.close(1000, 'Replaced by new connection');
|
||||
// Immediately destroy TCP socket — no graceful close needed for replaced connections
|
||||
if (typeof existing.ws.terminate === 'function') {
|
||||
existing.ws.terminate();
|
||||
} else {
|
||||
existing.ws.close(1000, 'Replaced by new connection');
|
||||
}
|
||||
}
|
||||
|
||||
const connection: EdgeConnection = {
|
||||
@@ -418,7 +445,7 @@ export function handleEdgeConnection(
|
||||
hostname: hello.hostname,
|
||||
capabilities: hello.capabilities,
|
||||
connectedAt: new Date(),
|
||||
lastHeartbeat: new Date(),
|
||||
lastHeartbeat: Date.now(),
|
||||
pendingRequests: new Map(),
|
||||
pendingStreamRequests: new Map()
|
||||
};
|
||||
@@ -471,7 +498,8 @@ export async function sendEdgeRequest(
|
||||
body?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
streaming = false,
|
||||
timeout = 30000
|
||||
timeout = 30000,
|
||||
isBinary = false
|
||||
): Promise<EdgeResponse> {
|
||||
const connection = edgeConnections.get(environmentId);
|
||||
if (!connection) {
|
||||
@@ -559,7 +587,8 @@ export async function sendEdgeRequest(
|
||||
method,
|
||||
path,
|
||||
headers: headers || {},
|
||||
body: body, // Body is already an object, will be serialized by JSON.stringify(message)
|
||||
body: body,
|
||||
isBinary, // true when body is base64-encoded binary (tar uploads)
|
||||
streaming
|
||||
};
|
||||
|
||||
@@ -753,7 +782,7 @@ export function handleEdgeResponse(environmentId: number, response: ResponseMess
|
||||
export function handleHeartbeat(environmentId: number): void {
|
||||
const connection = edgeConnections.get(environmentId);
|
||||
if (connection) {
|
||||
connection.lastHeartbeat = new Date();
|
||||
connection.lastHeartbeat = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,7 +853,8 @@ export interface RequestMessage {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown; // JSON-serializable object, will be serialized when message is stringified
|
||||
body?: unknown; // JSON-serializable object, or base64 string when isBinary=true
|
||||
isBinary?: boolean; // true when body is base64-encoded binary data (tar uploads)
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
@@ -946,3 +976,269 @@ export type HawserMessage =
|
||||
| ContainerEventMessage
|
||||
| { type: 'ping'; timestamp: number }
|
||||
| { type: 'pong'; timestamp: number };
|
||||
|
||||
// ─── Production WebSocket message handler (used by server.js) ───
|
||||
|
||||
// Maps WebSocket instances to environment IDs for message routing
|
||||
const wsToEnvId = new Map<any, number>();
|
||||
|
||||
// Auth fail cache to prevent brute-force token validation.
|
||||
// Entries are periodically cleaned up to prevent unbounded growth.
|
||||
const hawserAuthFailCache = new Map<string, number>();
|
||||
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 30_000;
|
||||
|
||||
// Periodic cleanup of expired auth fail entries (every 60s)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of hawserAuthFailCache) {
|
||||
if (now - timestamp > HAWSER_AUTH_FAIL_COOLDOWN_MS) {
|
||||
hawserAuthFailCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Reconnection storm throttle ───
|
||||
// Tracks per-environment reconnection frequency to detect storms
|
||||
// (e.g., agent can auth but Docker socket is broken → 30s timeout → reconnect loop)
|
||||
interface ReconnectTrackerEntry {
|
||||
timestamps: number[];
|
||||
cooldownUntil: number; // 0 = no cooldown active
|
||||
cooldownLevel: number; // index into COOLDOWN_LEVELS
|
||||
}
|
||||
const reconnectTracker = new Map<number, ReconnectTrackerEntry>();
|
||||
const RECONNECT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window
|
||||
const RECONNECT_BURST = 3; // allow 3 reconnections per window
|
||||
const COOLDOWN_LEVELS_SECS = [30, 60, 120, 300]; // escalating cooldown in seconds
|
||||
const STABLE_THRESHOLD_MS = 5 * 60 * 1000; // stable connection resets tracker
|
||||
const STALE_TRACKER_MS = 10 * 60 * 1000; // clean up stale tracker entries
|
||||
|
||||
/**
|
||||
* Record a reconnection for an environment and check if throttling is needed.
|
||||
* Returns { allowed: true } or { allowed: false, retryAfter: seconds }.
|
||||
*/
|
||||
function recordReconnection(envId: number): { allowed: true } | { allowed: false; retryAfter: number } {
|
||||
const now = Date.now();
|
||||
let entry = reconnectTracker.get(envId);
|
||||
|
||||
if (!entry) {
|
||||
entry = { timestamps: [now], cooldownUntil: 0, cooldownLevel: 0 };
|
||||
reconnectTracker.set(envId, entry);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Check if currently in cooldown
|
||||
if (now < entry.cooldownUntil) {
|
||||
const retryAfter = Math.ceil((entry.cooldownUntil - now) / 1000);
|
||||
return { allowed: false, retryAfter };
|
||||
}
|
||||
|
||||
// Prune timestamps outside the sliding window
|
||||
entry.timestamps = entry.timestamps.filter(ts => now - ts < RECONNECT_WINDOW_MS);
|
||||
entry.timestamps.push(now);
|
||||
|
||||
// Check if burst limit exceeded
|
||||
if (entry.timestamps.length > RECONNECT_BURST) {
|
||||
const level = Math.min(entry.cooldownLevel, COOLDOWN_LEVELS_SECS.length - 1);
|
||||
const cooldownSecs = COOLDOWN_LEVELS_SECS[level];
|
||||
entry.cooldownUntil = now + cooldownSecs * 1000;
|
||||
entry.cooldownLevel = Math.min(entry.cooldownLevel + 1, COOLDOWN_LEVELS_SECS.length - 1);
|
||||
|
||||
console.warn(
|
||||
`[Hawser WS] Reconnection storm detected for env ${envId}: ` +
|
||||
`${entry.timestamps.length} connections in ${RECONNECT_WINDOW_MS / 1000}s. ` +
|
||||
`Cooldown ${cooldownSecs}s (level ${level})`
|
||||
);
|
||||
|
||||
return { allowed: false, retryAfter: cooldownSecs };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a WebSocket message from a Hawser Edge agent.
|
||||
* Full protocol handler: hello/welcome auth, ping/pong,
|
||||
* response/stream routing, metrics, container events, exec sessions.
|
||||
*
|
||||
* Registered as globalThis.__hawserHandleMessage for server.js to call.
|
||||
*/
|
||||
async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise<void> {
|
||||
if (msg.type === 'hello') {
|
||||
const remoteAddr = connId;
|
||||
|
||||
// Rate limit auth failures
|
||||
const lastFail = hawserAuthFailCache.get(remoteAddr);
|
||||
if (lastFail && Date.now() - lastFail < HAWSER_AUTH_FAIL_COOLDOWN_MS) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Too many failed attempts' }));
|
||||
ws.close(1008, 'Rate limited');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.token) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'No token provided' }));
|
||||
ws.close(1008, 'Missing token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateHawserToken(msg.token);
|
||||
if (!result.valid || !result.environmentId) {
|
||||
console.log(`[Hawser WS] Authentication failed for connection ${connId}`);
|
||||
hawserAuthFailCache.set(remoteAddr, Date.now());
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
ws.close(1008, 'Invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle reconnection storms (successful auth but broken Docker = rapid reconnect loop)
|
||||
const throttle = recordReconnection(result.environmentId);
|
||||
if (!throttle.allowed) {
|
||||
console.log(`[Hawser WS] Throttling reconnection for env ${result.environmentId}: retry after ${throttle.retryAfter}s`);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `Reconnection throttled. Retry after ${throttle.retryAfter}s.`,
|
||||
retryAfter: throttle.retryAfter
|
||||
}));
|
||||
ws.close(1008, 'Reconnection throttled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated — register the connection
|
||||
const connection = handleEdgeConnection(ws, result.environmentId, msg);
|
||||
wsToEnvId.set(ws, result.environmentId);
|
||||
|
||||
// Send welcome
|
||||
ws.send(JSON.stringify({
|
||||
type: 'welcome',
|
||||
serverId: 'dockhand',
|
||||
version: HAWSER_PROTOCOL_VERSION
|
||||
}));
|
||||
|
||||
console.log(`[Hawser WS] Agent authenticated: env=${result.environmentId} agent=${msg.agentName || msg.agentId}`);
|
||||
} catch (error: any) {
|
||||
console.error('[Hawser WS] Auth error:', error.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Authentication failed' }));
|
||||
ws.close(1011, 'Auth error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other messages require an authenticated connection
|
||||
const envId = wsToEnvId.get(ws);
|
||||
if (!envId) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = edgeConnections.get(envId);
|
||||
if (!connection) return;
|
||||
|
||||
// Update heartbeat
|
||||
connection.lastHeartbeat = Date.now();
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'response': {
|
||||
const pending = connection.pendingRequests.get(msg.requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
connection.pendingRequests.delete(msg.requestId);
|
||||
pending.resolve({
|
||||
statusCode: msg.statusCode,
|
||||
headers: msg.headers || {},
|
||||
body: msg.body,
|
||||
isBinary: msg.isBinary
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream': {
|
||||
const streamPending = connection.pendingStreamRequests.get(msg.requestId);
|
||||
if (streamPending) {
|
||||
streamPending.onData?.(msg.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end': {
|
||||
const streamReq = connection.pendingStreamRequests.get(msg.requestId);
|
||||
if (streamReq) {
|
||||
connection.pendingStreamRequests.delete(msg.requestId);
|
||||
streamReq.onEnd?.(msg.reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'metrics':
|
||||
if (globalThis.__hawserHandleMetrics) {
|
||||
await globalThis.__hawserHandleMetrics(envId, msg.metrics);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'container_event':
|
||||
if (globalThis.__hawserHandleContainerEvent) {
|
||||
await globalThis.__hawserHandleContainerEvent(envId, msg.event);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'exec_ready':
|
||||
case 'exec_output':
|
||||
case 'exec_end':
|
||||
// Forward exec messages to server.js/vite.config.ts via global callback
|
||||
if (globalThis.__terminalHandleExecMessage) {
|
||||
globalThis.__terminalHandleExecMessage(msg);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error(`[Hawser WS] Agent error (env ${envId}): ${msg.message}`);
|
||||
// Forward exec-related errors (identified by requestId) to terminal handler
|
||||
if (msg.requestId && globalThis.__terminalHandleExecMessage) {
|
||||
globalThis.__terminalHandleExecMessage(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket disconnect for a Hawser Edge agent.
|
||||
* Receives the actual ws object to correctly identify which connection closed.
|
||||
*/
|
||||
function handleHawserWsDisconnect(disconnectedWs: any, connId: string): void {
|
||||
const envId = wsToEnvId.get(disconnectedWs);
|
||||
if (!envId) {
|
||||
// This ws was never authenticated (e.g., auth failed), nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = edgeConnections.get(envId);
|
||||
if (connection && connection.ws === disconnectedWs) {
|
||||
console.log(`[Hawser WS] Agent disconnected: env=${envId}`);
|
||||
|
||||
for (const [, pending] of connection.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Agent disconnected'));
|
||||
}
|
||||
for (const [, pending] of connection.pendingStreamRequests) {
|
||||
pending.onEnd?.('Agent disconnected');
|
||||
}
|
||||
connection.pendingRequests.clear();
|
||||
connection.pendingStreamRequests.clear();
|
||||
|
||||
edgeConnections.delete(envId);
|
||||
updateEnvironmentStatus(envId, null);
|
||||
}
|
||||
|
||||
wsToEnvId.delete(disconnectedWs);
|
||||
}
|
||||
|
||||
// Register global handlers for server.js to call
|
||||
globalThis.__hawserHandleMessage = handleHawserWsMessage;
|
||||
globalThis.__hawserHandleDisconnect = handleHawserWsDisconnect;
|
||||
+83
-11
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Cache the host data dir to avoid repeated API calls
|
||||
@@ -29,6 +30,11 @@ let detectionAttempted = false;
|
||||
// Cache ALL mounts for path translation (not just DATA_DIR)
|
||||
let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
||||
|
||||
// Cache Dockhand's own Docker access method (detected from container inspect)
|
||||
// Used by scanner to replicate how Dockhand connects to Docker
|
||||
let cachedOwnDockerHost: string | null = null;
|
||||
let cachedOwnNetworkMode: string | null = null;
|
||||
|
||||
/**
|
||||
* Get our own container ID
|
||||
*/
|
||||
@@ -95,25 +101,48 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
|
||||
try {
|
||||
// Query Docker API to inspect our own container
|
||||
// Try unix socket first, fall back to TCP if DOCKER_HOST is set
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
const dockerHost = process.env.DOCKER_HOST;
|
||||
|
||||
// Use fetch with unix socket
|
||||
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
const containerInfo = await new Promise<any>((resolvePromise, reject) => {
|
||||
const reqOptions: http.RequestOptions = dockerHost?.startsWith('tcp://')
|
||||
? (() => {
|
||||
const u = new URL(dockerHost.replace('tcp://', 'http://'));
|
||||
return { hostname: u.hostname, port: u.port, path: `/containers/${containerId}/json`, method: 'GET' };
|
||||
})()
|
||||
: { socketPath, path: `/containers/${containerId}/json`, method: 'GET' };
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[HostPath] Failed to inspect container: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerInfo = await response.json() as {
|
||||
const req = http.request(reqOptions, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
resolvePromise(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
||||
} catch {
|
||||
reject(new Error('Failed to parse container inspect response'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Container inspect failed: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
}) as {
|
||||
Mounts?: Array<{
|
||||
Type: string;
|
||||
Source: string;
|
||||
Destination: string;
|
||||
}>;
|
||||
Config?: {
|
||||
Env?: string[];
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Networks?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
// Cache ALL mounts for later path translation (used by rewriteComposeVolumePaths)
|
||||
@@ -123,6 +152,30 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
}));
|
||||
console.log(`[HostPath] Cached ${cachedMounts.length} mount(s)`);
|
||||
|
||||
// Cache DOCKER_HOST from Dockhand's own env vars (if set)
|
||||
// This tells us how Dockhand was configured to reach Docker
|
||||
const envVars = containerInfo.Config?.Env || [];
|
||||
for (const v of envVars) {
|
||||
if (v.startsWith('DOCKER_HOST=')) {
|
||||
cachedOwnDockerHost = v.substring('DOCKER_HOST='.length);
|
||||
console.log(`[HostPath] Detected own DOCKER_HOST: ${cachedOwnDockerHost}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Dockhand's network (prefer non-default for service discovery)
|
||||
const networks = containerInfo.NetworkSettings?.Networks;
|
||||
if (networks) {
|
||||
const custom = Object.keys(networks).filter(
|
||||
n => n !== 'bridge' && n !== 'none' && n !== 'host'
|
||||
);
|
||||
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
|
||||
: networks.bridge ? 'bridge' : null;
|
||||
if (cachedOwnNetworkMode) {
|
||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the mount for our DATA_DIR
|
||||
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
|
||||
|
||||
@@ -157,6 +210,25 @@ export function getHostDataDir(): string | null {
|
||||
return cachedHostDataDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DOCKER_HOST from Dockhand's own container config (if set).
|
||||
* Returns the TCP address (e.g., "tcp://socket-proxy:2375") or null.
|
||||
* Populated by detectHostDataDir() at startup.
|
||||
*/
|
||||
export function getOwnDockerHost(): string | null {
|
||||
return cachedOwnDockerHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Docker network Dockhand is attached to.
|
||||
* Used to place scanner containers on the same network so they can reach
|
||||
* TCP-based Docker endpoints (e.g., socket proxy).
|
||||
* Populated by detectHostDataDir() at startup.
|
||||
*/
|
||||
export function getOwnNetworkMode(): string | null {
|
||||
return cachedOwnNetworkMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a container path to host path
|
||||
*
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface JobLine {
|
||||
event?: string; // 'result', 'progress', etc. — undefined for bare data lines
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
lines: JobLine[];
|
||||
result?: unknown;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const jobs = new Map<string, Job>();
|
||||
|
||||
export function createJob(): Job {
|
||||
const job: Job = {
|
||||
id: randomUUID(),
|
||||
status: 'running',
|
||||
lines: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getJob(id: string): Job | undefined {
|
||||
return jobs.get(id);
|
||||
}
|
||||
|
||||
export function appendLine(job: Job, line: JobLine): void {
|
||||
job.lines.push(line);
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function completeJob(job: Job, result: unknown): void {
|
||||
job.result = result;
|
||||
job.status = 'done';
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function failJob(job: Job, error: string): void {
|
||||
job.result = { success: false, error };
|
||||
job.status = 'error';
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
// Cleanup jobs older than 10 minutes that are no longer running
|
||||
const CLEANUP_INTERVAL_MS = 60_000;
|
||||
const JOB_TTL_MS = 10 * 60_000;
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - JOB_TTL_MS;
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.status !== 'running' && job.updatedAt < cutoff) {
|
||||
jobs.delete(id);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* In-Memory Metrics Ring Buffer
|
||||
*
|
||||
* Replaces SQLite/PostgreSQL host_metrics storage with a fixed-size
|
||||
* in-memory circular buffer per environment. Uses pre-allocated arrays
|
||||
* with head/count indices to avoid splice()-based eviction which causes
|
||||
* V8 to repeatedly reallocate backing arrays.
|
||||
*
|
||||
* Memory: 16 envs × 360 slots × ~100 bytes ≈ 576 KB
|
||||
*/
|
||||
|
||||
export interface MetricPoint {
|
||||
id: number;
|
||||
cpuPercent: number;
|
||||
memoryPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryTotal: number;
|
||||
environmentId: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const MAX_POINTS_PER_ENV = 360; // 1 hour at 10s interval, 3 hours at 30s
|
||||
|
||||
interface RingBuffer {
|
||||
data: (MetricPoint | null)[];
|
||||
head: number; // next write position
|
||||
count: number; // number of valid entries (≤ MAX_POINTS_PER_ENV)
|
||||
}
|
||||
|
||||
// envId → RingBuffer
|
||||
const store = new Map<number, RingBuffer>();
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
/**
|
||||
* Push a new metric data point for an environment.
|
||||
* Overwrites oldest entry when buffer is full (no array reallocation).
|
||||
*/
|
||||
export function pushMetric(
|
||||
envId: number,
|
||||
cpuPercent: number,
|
||||
memoryPercent: number,
|
||||
memoryUsed: number,
|
||||
memoryTotal: number
|
||||
): void {
|
||||
let ring = store.get(envId);
|
||||
if (!ring) {
|
||||
ring = { data: new Array(MAX_POINTS_PER_ENV).fill(null), head: 0, count: 0 };
|
||||
store.set(envId, ring);
|
||||
}
|
||||
|
||||
ring.data[ring.head] = {
|
||||
id: nextId++,
|
||||
cpuPercent,
|
||||
memoryPercent,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
environmentId: envId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
ring.head = (ring.head + 1) % MAX_POINTS_PER_ENV;
|
||||
if (ring.count < MAX_POINTS_PER_ENV) ring.count++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read entries from a ring buffer in oldest-first order.
|
||||
*/
|
||||
function readRing(ring: RingBuffer, limit: number): MetricPoint[] {
|
||||
const count = Math.min(ring.count, limit);
|
||||
if (count === 0) return [];
|
||||
|
||||
const result: MetricPoint[] = new Array(count);
|
||||
// Start reading from the oldest entry
|
||||
const start = (ring.head - ring.count + MAX_POINTS_PER_ENV) % MAX_POINTS_PER_ENV;
|
||||
const skip = ring.count - count;
|
||||
const readFrom = (start + skip) % MAX_POINTS_PER_ENV;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = ring.data[(readFrom + i) % MAX_POINTS_PER_ENV]!;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent metric for an environment.
|
||||
*/
|
||||
export function getLatestMetric(envId: number): MetricPoint | null {
|
||||
const ring = store.get(envId);
|
||||
if (!ring || ring.count === 0) return null;
|
||||
// head points to next write position, so latest is head - 1
|
||||
const idx = (ring.head - 1 + MAX_POINTS_PER_ENV) % MAX_POINTS_PER_ENV;
|
||||
return ring.data[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics history for an environment, oldest first.
|
||||
*/
|
||||
export function getMetricsHistory(envId: number, limit = 60): MetricPoint[] {
|
||||
const ring = store.get(envId);
|
||||
if (!ring || ring.count === 0) return [];
|
||||
return readRing(ring, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics (across all environments), newest first, with optional limit.
|
||||
* Used by the global getHostMetrics() fallback when no envId is specified.
|
||||
*/
|
||||
export function getAllMetrics(limit = 60): MetricPoint[] {
|
||||
const all: MetricPoint[] = [];
|
||||
for (const ring of store.values()) {
|
||||
const points = readRing(ring, ring.count);
|
||||
all.push(...points);
|
||||
}
|
||||
// Sort newest first (matching old DB query behavior)
|
||||
all.sort((a, b) => b.id - a.id);
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics for an environment (e.g., when environment is deleted).
|
||||
*/
|
||||
export function clearEnvironmentMetrics(envId: number): void {
|
||||
store.delete(envId);
|
||||
}
|
||||
@@ -21,6 +21,13 @@ function escapeTelegramMarkdown(text: string): string {
|
||||
.replace(/`/g, '\\`'); // Backtick (code)
|
||||
}
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
async function drainResponse(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
try { await response.arrayBuffer(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -35,21 +42,47 @@ export interface NotificationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// SMTP transporter cache — reuses connections instead of creating a new TLS pool per notification.
|
||||
const transporterCache = new Map<string, { transporter: ReturnType<typeof nodemailer.createTransport>; lastUsed: number }>();
|
||||
|
||||
function getOrCreateTransporter(config: SmtpConfig): ReturnType<typeof nodemailer.createTransport> {
|
||||
const key = `${config.host}:${config.port}:${config.secure}:${config.username || ''}`;
|
||||
const cached = transporterCache.get(key);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.transporter;
|
||||
}
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
transporterCache.set(key, { transporter, lastUsed: Date.now() });
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Clean up idle transporters every 10 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of transporterCache) {
|
||||
if (now - entry.lastUsed > 10 * 60 * 1000) {
|
||||
entry.transporter.close();
|
||||
transporterCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
// Send notification via SMTP
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
const transporter = getOrCreateTransporter(config);
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
@@ -172,6 +205,7 @@ async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Pr
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -204,6 +238,7 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -211,7 +246,7 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom
|
||||
}
|
||||
|
||||
// Mattermost webhook
|
||||
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
|
||||
const isSecure = appriseUrl.startsWith('mmosts');
|
||||
const protocol = isSecure ? 'https' : 'http';
|
||||
@@ -230,8 +265,7 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
// The token is the last segment, everything else is hostname[:port][/path]
|
||||
const lastSlashIndex = urlPart.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
console.error('[Notifications] Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token');
|
||||
return false;
|
||||
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
|
||||
}
|
||||
|
||||
const token = urlPart.substring(lastSlashIndex + 1);
|
||||
@@ -249,13 +283,22 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
body.username = username;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram
|
||||
@@ -290,6 +333,7 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -328,6 +372,7 @@ async function sendGotify(appriseUrl: string, payload: NotificationPayload): Pro
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -394,6 +439,7 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -428,6 +474,7 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -455,6 +502,7 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* RSS Tracker — Per-operation native memory delta tracking
|
||||
*
|
||||
* Measures process.memoryUsage().rss before and after instrumented operations
|
||||
* to identify which background operation is responsible for native memory growth.
|
||||
*
|
||||
* All functions are no-ops when MEMORY_MONITOR !== 'true'.
|
||||
*/
|
||||
|
||||
import v8 from 'node:v8';
|
||||
import { existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CategoryStats {
|
||||
count: number;
|
||||
totalDelta: number;
|
||||
maxDelta: number;
|
||||
// Lifetime cumulative (never reset)
|
||||
lifetimeCount: number;
|
||||
lifetimeTotalDelta: number;
|
||||
}
|
||||
|
||||
interface RssSnapshot {
|
||||
filename: string;
|
||||
timestamp: string;
|
||||
uptimeMin: number;
|
||||
rssMB: number;
|
||||
sizeMB: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const enabled = process.env.MEMORY_MONITOR === 'true';
|
||||
|
||||
const categories = new Map<string, CategoryStats>();
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let snapshotIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let periodNumber = 0;
|
||||
let periodStartRss = 0;
|
||||
const startupTime = Date.now();
|
||||
const startupRss = enabled ? process.memoryUsage().rss : 0;
|
||||
|
||||
// Snapshot settings
|
||||
const SNAPSHOT_WARMUP_MS = 5 * 60 * 1000; // 5 min before first snapshot
|
||||
const SNAPSHOT_INTERVAL_MS = parseInt(process.env.SNAPSHOT_INTERVAL || '60', 10) * 60 * 1000;
|
||||
const MAX_SNAPSHOTS = 48;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Capture RSS before an operation. Returns the RSS value.
|
||||
* No-op (returns 0) when MEMORY_MONITOR is not set.
|
||||
*/
|
||||
export function rssBeforeOp(): number {
|
||||
if (!enabled) return 0;
|
||||
return process.memoryUsage().rss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record RSS delta after an operation.
|
||||
* No-op when MEMORY_MONITOR is not set.
|
||||
*/
|
||||
export function rssAfterOp(category: string, before: number): void {
|
||||
if (!enabled || before === 0) return;
|
||||
|
||||
const after = process.memoryUsage().rss;
|
||||
const delta = after - before;
|
||||
|
||||
let stats = categories.get(category);
|
||||
if (!stats) {
|
||||
stats = { count: 0, totalDelta: 0, maxDelta: 0, lifetimeCount: 0, lifetimeTotalDelta: 0 };
|
||||
categories.set(category, stats);
|
||||
}
|
||||
|
||||
stats.count++;
|
||||
stats.totalDelta += delta;
|
||||
if (Math.abs(delta) > Math.abs(stats.maxDelta)) stats.maxDelta = delta;
|
||||
|
||||
stats.lifetimeCount++;
|
||||
stats.lifetimeTotalDelta += delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stats summary.
|
||||
*/
|
||||
export function getRssStats() {
|
||||
if (!enabled) return null;
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const uptimeMs = Date.now() - startupTime;
|
||||
const uptimeHours = uptimeMs / (1000 * 60 * 60);
|
||||
const rssGrowth = mem.rss - startupRss;
|
||||
|
||||
const perCategory: Record<string, {
|
||||
count: number;
|
||||
avgDelta: string;
|
||||
maxDelta: string;
|
||||
totalDelta: string;
|
||||
lifetimeCount: number;
|
||||
lifetimeTotalDelta: string;
|
||||
}> = {};
|
||||
|
||||
for (const [cat, stats] of categories) {
|
||||
perCategory[cat] = {
|
||||
count: stats.count,
|
||||
avgDelta: stats.count > 0 ? fmtBytes(Math.round(stats.totalDelta / stats.count)) : '0',
|
||||
maxDelta: fmtBytes(stats.maxDelta),
|
||||
totalDelta: fmtBytes(stats.totalDelta),
|
||||
lifetimeCount: stats.lifetimeCount,
|
||||
lifetimeTotalDelta: fmtBytes(stats.lifetimeTotalDelta),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
periodNumber,
|
||||
rssMB: fmtMB(mem.rss),
|
||||
rssGrowthTotal: fmtBytes(rssGrowth),
|
||||
rssGrowthPerHour: fmtBytes(uptimeHours > 0.01 ? rssGrowth / uptimeHours : 0),
|
||||
uptimeHours: Math.round(uptimeHours * 100) / 100,
|
||||
categories: perCategory,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Periodic logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function logPeriodSummary(): void {
|
||||
periodNumber++;
|
||||
const mem = process.memoryUsage();
|
||||
const rssDelta = mem.rss - periodStartRss;
|
||||
const uptimeMs = Date.now() - startupTime;
|
||||
const uptimeHours = uptimeMs / (1000 * 60 * 60);
|
||||
const rssGrowthTotal = mem.rss - startupRss;
|
||||
const rssPerHour = uptimeHours > 0.01 ? rssGrowthTotal / uptimeHours : 0;
|
||||
|
||||
let summary = `[RSS] #${periodNumber} rss=${fmtMB(mem.rss)}(${fmtDelta(rssDelta)}) total=${fmtDelta(rssGrowthTotal)} rate=${fmtBytes(Math.round(rssPerHour))}/h`;
|
||||
|
||||
// Sort categories by absolute totalDelta descending
|
||||
const sorted = [...categories.entries()]
|
||||
.filter(([, s]) => s.count > 0)
|
||||
.sort((a, b) => Math.abs(b[1].totalDelta) - Math.abs(a[1].totalDelta));
|
||||
|
||||
let accountedDelta = 0;
|
||||
for (const [cat, stats] of sorted) {
|
||||
const avg = stats.count > 0 ? Math.round(stats.totalDelta / stats.count) : 0;
|
||||
summary += `\n ${cat.padEnd(14)} n=${String(stats.count).padStart(4)} avg=${fmtDelta(avg).padStart(7)} max=${fmtDelta(stats.maxDelta).padStart(7)} total=${fmtDelta(stats.totalDelta).padStart(7)}`;
|
||||
accountedDelta += stats.totalDelta;
|
||||
}
|
||||
|
||||
const unaccounted = rssDelta - accountedDelta;
|
||||
if (sorted.length > 0) {
|
||||
summary += `\n ${'unaccounted'.padEnd(14)} ${fmtDelta(unaccounted).padStart(7)}`;
|
||||
}
|
||||
|
||||
console.log(summary);
|
||||
|
||||
// Reset per-period counters (keep lifetime)
|
||||
for (const stats of categories.values()) {
|
||||
stats.count = 0;
|
||||
stats.totalDelta = 0;
|
||||
stats.maxDelta = 0;
|
||||
}
|
||||
periodStartRss = mem.rss;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heap snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSnapshotDir(): string {
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
return join(dataDir, 'snapshots');
|
||||
}
|
||||
|
||||
function ensureSnapshotDir(): string {
|
||||
const dir = getSnapshotDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanupOldSnapshots(dir: string): void {
|
||||
try {
|
||||
const files = readdirSync(dir)
|
||||
.filter(f => f.endsWith('.heapsnapshot'))
|
||||
.map(f => ({ name: f, time: statSync(join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
while (files.length > MAX_SNAPSHOTS) {
|
||||
const oldest = files.shift()!;
|
||||
try {
|
||||
unlinkSync(join(dir, oldest.name));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump a V8 heap snapshot to disk. Returns the filename.
|
||||
*/
|
||||
export function dumpHeapSnapshot(): string | null {
|
||||
if (!enabled) return null;
|
||||
|
||||
const dir = ensureSnapshotDir();
|
||||
cleanupOldSnapshots(dir);
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const uptimeMin = Math.round((Date.now() - startupTime) / 60000);
|
||||
const rssMB = Math.round(mem.rss / (1024 * 1024));
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').substring(0, 19);
|
||||
const filename = `heap-${ts}-${uptimeMin}m-${rssMB}mb.heapsnapshot`;
|
||||
const filepath = join(dir, filename);
|
||||
|
||||
try {
|
||||
v8.writeHeapSnapshot(filepath);
|
||||
console.log(`[RSS] Heap snapshot saved: ${filepath}`);
|
||||
return filename;
|
||||
} catch (err) {
|
||||
console.error(`[RSS] Failed to write heap snapshot:`, err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List saved heap snapshots.
|
||||
*/
|
||||
export function listHeapSnapshots(): RssSnapshot[] {
|
||||
const dir = getSnapshotDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter(f => f.endsWith('.heapsnapshot'))
|
||||
.map(f => {
|
||||
const stat = statSync(join(dir, f));
|
||||
// Parse filename: heap-YYYY-MM-DD-HH-MM-SS-{uptime}m-{rss}mb.heapsnapshot
|
||||
const match = f.match(/heap-(.+?)-(\d+)m-(\d+)mb\.heapsnapshot/);
|
||||
return {
|
||||
filename: f,
|
||||
timestamp: stat.mtime.toISOString(),
|
||||
uptimeMin: match ? parseInt(match[2]) : 0,
|
||||
rssMB: match ? parseInt(match[3]) : 0,
|
||||
sizeMB: Math.round(stat.size / (1024 * 1024) * 10) / 10,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoSnapshots(): void {
|
||||
// First snapshot after warmup
|
||||
setTimeout(() => {
|
||||
dumpHeapSnapshot();
|
||||
|
||||
// Then every SNAPSHOT_INTERVAL_MS
|
||||
snapshotIntervalHandle = setInterval(() => {
|
||||
dumpHeapSnapshot();
|
||||
}, SNAPSHOT_INTERVAL_MS);
|
||||
}, SNAPSHOT_WARMUP_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the RSS tracker. Call once on startup.
|
||||
*/
|
||||
export function startRssTracker(): void {
|
||||
if (!enabled) return;
|
||||
|
||||
periodStartRss = process.memoryUsage().rss;
|
||||
console.log(`[RSS] Tracker started. Initial RSS: ${fmtMB(periodStartRss)}. Logging every 60s.`);
|
||||
|
||||
intervalHandle = setInterval(logPeriodSummary, 60_000);
|
||||
|
||||
startAutoSnapshots();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RSS tracker.
|
||||
*/
|
||||
export function stopRssTracker(): void {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
}
|
||||
if (snapshotIntervalHandle) {
|
||||
clearInterval(snapshotIntervalHandle);
|
||||
snapshotIntervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtMB(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
const abs = Math.abs(bytes);
|
||||
const sign = bytes < 0 ? '-' : '+';
|
||||
if (abs < 1024) return `${sign}${abs}B`;
|
||||
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)}K`;
|
||||
return `${sign}${(abs / (1024 * 1024)).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function fmtDelta(bytes: number): string {
|
||||
return fmtBytes(bytes);
|
||||
}
|
||||
+53
-34
@@ -15,7 +15,7 @@ import {
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath } from './host-path';
|
||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown } from 'node:fs/promises';
|
||||
|
||||
@@ -306,8 +306,18 @@ export function sanitizeJsonString(json: string): string {
|
||||
const ch2 = json[i];
|
||||
if ('"\\\/bfnrtu'.includes(ch2)) {
|
||||
result += ch2;
|
||||
} else if (ch < 0x20) {
|
||||
// Backslash followed by a raw control character (e.g. \ + 0x0A)
|
||||
// The backslash was already added to result — escape it as \\
|
||||
// then also escape the control character
|
||||
result += '\\';
|
||||
if (ch === 0x0A) result += '\\n';
|
||||
else if (ch === 0x0D) result += '\\r';
|
||||
else if (ch === 0x09) result += '\\t';
|
||||
else result += `\\u${ch.toString(16).padStart(4, '0')}`;
|
||||
sanitized++;
|
||||
} else {
|
||||
// Invalid escape like \x, \a, \0, \_ — convert backslash to literal \\
|
||||
// Invalid escape like \x, \a, \e — convert backslash to literal \\
|
||||
result += '\\' + ch2;
|
||||
sanitized++;
|
||||
}
|
||||
@@ -341,7 +351,7 @@ export function sanitizeJsonString(json: string): string {
|
||||
}
|
||||
|
||||
if (sanitized > 0) {
|
||||
console.warn(`[Scanner] Sanitized ${sanitized} control characters in JSON output`);
|
||||
console.warn(`[Scanner] Sanitized ${sanitized} control/escape characters in JSON output`);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -359,12 +369,10 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s
|
||||
unknown: 0
|
||||
};
|
||||
|
||||
console.log('[Grype] Raw output length:', output.length);
|
||||
console.log('[Grype] Output starts with:', output.slice(0, 200));
|
||||
console.log('[Grype] Output ends with:', JSON.stringify(output.slice(-50)));
|
||||
|
||||
try {
|
||||
const data = JSON.parse(sanitizeJsonString(extractJson(output)));
|
||||
const extracted = extractJson(output);
|
||||
const sanitized = sanitizeJsonString(extracted);
|
||||
const data = JSON.parse(sanitized);
|
||||
|
||||
if (data.matches) {
|
||||
for (const match of data.matches) {
|
||||
@@ -587,42 +595,45 @@ async function runScannerContainerCore(
|
||||
const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy';
|
||||
const dbPath = basePath;
|
||||
|
||||
// Detect the host Docker socket path based on connection type
|
||||
// For local socket environments, detect the actual host socket path (handles rootless Docker)
|
||||
// For remote environments (hawser/direct with host), scanner runs remotely and uses standard path
|
||||
// Detect how the scanner container should access Docker.
|
||||
// Strategy: mirror Dockhand's own Docker connection when running locally.
|
||||
const env = envId ? await getEnvironment(envId) : undefined;
|
||||
const connectionType = env?.connectionType;
|
||||
|
||||
// Determine if this is a local socket environment:
|
||||
// - connectionType === 'socket' (explicit)
|
||||
// - connectionType is null/undefined (default behavior)
|
||||
// - connectionType === 'direct' but no host specified (legacy local environments)
|
||||
const isLocalSocket = !connectionType ||
|
||||
connectionType === 'socket' ||
|
||||
(connectionType === 'direct' && !env?.host);
|
||||
const isHawser = connectionType === 'hawser-standard' || connectionType === 'hawser-edge';
|
||||
|
||||
let hostSocketPath: string;
|
||||
let hostSocketPath: string | null = null;
|
||||
let rootlessUid: string | undefined;
|
||||
let scannerNetworkMode: string | undefined;
|
||||
let scannerDockerHost: string | undefined;
|
||||
|
||||
if (isLocalSocket) {
|
||||
// Local socket environment - detect host socket path (handles rootless Docker)
|
||||
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
||||
// Detected at startup from Dockhand's own container inspect data.
|
||||
// This applies to ALL non-hawser environments since the scanner container
|
||||
// runs on the same Docker daemon and needs the same access method.
|
||||
const ownDockerHost = getOwnDockerHost();
|
||||
|
||||
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
|
||||
scannerDockerHost = ownDockerHost;
|
||||
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
||||
console.log(`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`);
|
||||
} else if (isHawser) {
|
||||
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - using standard socket path`);
|
||||
} else {
|
||||
// Local socket — detect host socket path (handles rootless Docker)
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`);
|
||||
|
||||
// For user-specific Docker sockets (rootless Docker), detect UID for cache ownership
|
||||
// but do NOT set container user — in rootless Docker, root inside the container
|
||||
// maps to the socket-owning UID on the host via user namespace remapping
|
||||
const uid = extractUidFromSocketPath(hostSocketPath);
|
||||
if (uid) {
|
||||
rootlessUid = uid;
|
||||
console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`);
|
||||
console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`);
|
||||
}
|
||||
} else {
|
||||
// Remote environment (direct with host/hawser-standard/hawser-edge)
|
||||
// Scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan (${connectionType}, host: ${env?.host}) - using standard socket path: ${hostSocketPath}`);
|
||||
}
|
||||
|
||||
// Determine cache storage strategy based on environment
|
||||
@@ -643,10 +654,12 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] Standard mode - using volume: ${volumeName}`);
|
||||
}
|
||||
|
||||
const binds = [
|
||||
`${hostSocketPath}:/var/run/docker.sock:ro`,
|
||||
cacheBind
|
||||
];
|
||||
// Build binds — only include socket mount when using socket mode
|
||||
const binds: string[] = [];
|
||||
if (hostSocketPath) {
|
||||
binds.push(`${hostSocketPath}:/var/run/docker.sock:ro`);
|
||||
}
|
||||
binds.push(cacheBind);
|
||||
|
||||
console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`);
|
||||
|
||||
@@ -655,6 +668,11 @@ async function runScannerContainerCore(
|
||||
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
|
||||
: [`TRIVY_CACHE_DIR=${dbPath}`];
|
||||
|
||||
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
|
||||
if (scannerDockerHost) {
|
||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||
}
|
||||
|
||||
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
|
||||
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
|
||||
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
|
||||
@@ -665,6 +683,7 @@ async function runScannerContainerCore(
|
||||
env: envVars,
|
||||
name: `dockhand-${scannerType}-${Date.now()}`,
|
||||
envId,
|
||||
networkMode: scannerNetworkMode,
|
||||
timeout: 600_000, // 10 minutes
|
||||
onStderr: (data) => {
|
||||
// Stream stderr lines for real-time progress output
|
||||
@@ -680,8 +699,8 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
||||
if (output.length === 0) {
|
||||
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
|
||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`);
|
||||
console.error(`[Scanner] Host socket path used: ${hostSocketPath}`);
|
||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker`);
|
||||
console.error(`[Scanner] Docker access: ${scannerDockerHost ? `TCP ${scannerDockerHost}` : `socket ${hostSocketPath}`}`);
|
||||
} else if (output.length < 100) {
|
||||
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
||||
}
|
||||
|
||||
@@ -569,9 +569,9 @@ export async function runContainerUpdate(
|
||||
// =============================================================================
|
||||
|
||||
log(`Recreating container with full config passthrough...`);
|
||||
const success = await recreateContainer(containerName, envId, log, imageNameFromConfig);
|
||||
const result = await recreateContainer(containerName, envId, log, imageNameFromConfig);
|
||||
|
||||
if (success) {
|
||||
if (result.success) {
|
||||
await updateAutoUpdateLastUpdated(containerName, envId);
|
||||
log(`Successfully updated container: ${containerName}`);
|
||||
|
||||
@@ -594,7 +594,7 @@ export async function runContainerUpdate(
|
||||
type: 'success'
|
||||
}, envId);
|
||||
} else {
|
||||
throw new Error('Failed to recreate container');
|
||||
throw new Error(result.error || 'Failed to recreate container');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -628,14 +628,14 @@ export async function recreateContainer(
|
||||
envId?: number,
|
||||
log?: (msg: string) => void,
|
||||
imageNameOverride?: string
|
||||
): Promise<boolean> {
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const containers = await listContainers(true, envId);
|
||||
const container = containers.find(c => c.name === containerName);
|
||||
|
||||
if (!container) {
|
||||
log?.(`Container not found: ${containerName}`);
|
||||
return false;
|
||||
return { success: false, error: `Container not found: ${containerName}` };
|
||||
}
|
||||
|
||||
const inspectData = await inspectContainer(container.id, envId) as any;
|
||||
@@ -644,10 +644,10 @@ export async function recreateContainer(
|
||||
log?.(`Recreating container: ${containerName} (image: ${imageName})`);
|
||||
|
||||
await recreateContainerFromInspect(inspectData, imageName, envId, log);
|
||||
return true;
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log?.(`Failed to recreate container: ${error.message}`);
|
||||
return false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,9 +352,9 @@ export async function runEnvUpdateCheckJob(
|
||||
|
||||
// Recreate container with full config passthrough
|
||||
await log(` Recreating container...`);
|
||||
const ok = await recreateContainer(update.containerName, environmentId,
|
||||
const result = await recreateContainer(update.containerName, environmentId,
|
||||
(msg) => { log(` ${msg}`); });
|
||||
if (!ok) throw new Error('Container recreation failed');
|
||||
if (!result.success) throw new Error(result.error || 'Container recreation failed');
|
||||
|
||||
await log(` Updated successfully`);
|
||||
successCount++;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Check if the client prefers JSON over SSE.
|
||||
* Returns true when Accept header includes application/json but NOT text/event-stream.
|
||||
*/
|
||||
export function prefersJSON(request?: Request): boolean {
|
||||
const accept = request?.headers.get('accept') || '';
|
||||
return accept.includes('application/json') && !accept.includes('text/event-stream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an SSE Response for JSON-preferring clients.
|
||||
*
|
||||
* Consumes the SSE stream using proper event framing (blank-line delimited,
|
||||
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
|
||||
* data as a JSON response, or a fallback if no result event was emitted.
|
||||
*
|
||||
* Usage:
|
||||
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
|
||||
* return buildSSEResponse();
|
||||
*/
|
||||
export async function sseToJSON(sseResponse: Response): Promise<Response> {
|
||||
const reader = sseResponse.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
let dataLines: string[] = [];
|
||||
let resultData: unknown = null;
|
||||
|
||||
const dispatch = () => {
|
||||
const data = dataLines.join('\n');
|
||||
const type = eventType || 'message';
|
||||
eventType = '';
|
||||
dataLines = [];
|
||||
if (type === 'result' && data) {
|
||||
try {
|
||||
resultData = JSON.parse(data);
|
||||
} catch {
|
||||
// keep previous resultData
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseLine = (rawLine: string) => {
|
||||
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line.startsWith(':')) return;
|
||||
if (line === '') { dispatch(); return; }
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let val = colon === -1 ? '' : line.slice(colon + 1);
|
||||
if (val.startsWith(' ')) val = val.slice(1);
|
||||
if (field === 'event') eventType = val || 'message';
|
||||
else if (field === 'data') dataLines.push(val);
|
||||
};
|
||||
|
||||
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) parseLine(line);
|
||||
}
|
||||
|
||||
// Flush remaining bytes and process trailing content
|
||||
buffer += decoder.decode();
|
||||
if (buffer) {
|
||||
for (const line of buffer.split('\n')) parseLine(line);
|
||||
}
|
||||
// Final dispatch for servers missing trailing blank line
|
||||
if (dataLines.length > 0) dispatch();
|
||||
} catch {
|
||||
// stream error, return what we have
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const body = resultData ?? { success: false, error: 'No result' };
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Job-based response for long-running operations.
|
||||
*
|
||||
* Backward compat: API clients that send `Accept: application/json` (and not
|
||||
* `text/event-stream`) get a synchronous JSON result directly.
|
||||
*
|
||||
* All other clients receive `{ jobId }` immediately. The operation runs in the
|
||||
* background and results accumulate in the job store. Clients poll /api/jobs/{id}.
|
||||
*
|
||||
* The send() callback stores lines with { event, data } so the polling client
|
||||
* can reconstruct the same event stream semantics used by the old SSE flow.
|
||||
*/
|
||||
export function createJobResponse(
|
||||
operation: (send: (event: string, data: unknown) => void) => Promise<void>,
|
||||
request?: Request
|
||||
): Response {
|
||||
// Backward compat: synchronous JSON path for explicit application/json callers
|
||||
if (prefersJSON(request)) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let resultData: unknown = { success: false, error: 'No result' };
|
||||
const send = (_event: string, data: unknown) => {
|
||||
resultData = data;
|
||||
};
|
||||
try {
|
||||
await operation(send);
|
||||
} catch (error) {
|
||||
resultData = { success: false, error: String(error) };
|
||||
}
|
||||
controller.enqueue(encoder.encode(JSON.stringify(resultData)));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Fire and forget: create job, run operation in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const send = (event: string, data: unknown) => {
|
||||
appendLine(job, { event, data });
|
||||
};
|
||||
|
||||
operation(send)
|
||||
.then(() => {
|
||||
const resultLine = job.lines.findLast((l) => l.event === 'result');
|
||||
completeJob(job, resultLine?.data ?? { success: true });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return json({ jobId: job.id });
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Discovered stacks are editable - compose and .env files are modified in their original location.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join, basename, dirname, resolve } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
|
||||
@@ -57,8 +57,7 @@ export function normalizeStackName(name: string): string {
|
||||
*/
|
||||
async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
// Basic check for services key - could be more sophisticated
|
||||
return /^services:/m.test(content) || /\nservices:/m.test(content);
|
||||
} catch {
|
||||
@@ -72,8 +71,7 @@ async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
*/
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const doc = yaml.load(content) as Record<string, unknown> | null;
|
||||
if (doc?.services && typeof doc.services === 'object') {
|
||||
return Object.keys(doc.services).length;
|
||||
|
||||
+96
-49
@@ -7,6 +7,8 @@
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -178,13 +180,47 @@ async function withStackLock<T>(stackName: string, fn: () => Promise<T>): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout configuration for compose operations
|
||||
const COMPOSE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
// Timeout configuration for compose operations (configurable via COMPOSE_TIMEOUT env var in seconds)
|
||||
const COMPOSE_TIMEOUT_MS = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000; // Default 15 min
|
||||
const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL
|
||||
|
||||
/**
|
||||
* Check if content is binary (not valid UTF-8 text).
|
||||
*/
|
||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
function isBinaryContent(bytes: Uint8Array): boolean {
|
||||
try {
|
||||
utf8Decoder.decode(bytes);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect stdout/stderr from a child process and wait for it to exit.
|
||||
*/
|
||||
function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||
stderr: Buffer.concat(stderrChunks).toString()
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all files from a directory as a map of relative path -> content.
|
||||
* Used to send files to Hawser for remote deployments.
|
||||
* Binary files are base64-encoded with a "base64:" prefix to preserve all bytes.
|
||||
*/
|
||||
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
@@ -200,9 +236,12 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
if (entry.name === '.git') continue;
|
||||
await scanDir(fullPath, relPath);
|
||||
} else if (entry.isFile()) {
|
||||
// Read file content
|
||||
const content = await Bun.file(fullPath).text();
|
||||
files[relPath] = content;
|
||||
const bytes = readFileSync(fullPath);
|
||||
if (isBinaryContent(bytes)) {
|
||||
files[relPath] = `base64:${bytes.toString('base64')}`;
|
||||
} else {
|
||||
files[relPath] = new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,7 +421,7 @@ export async function getStackComposeFile(
|
||||
};
|
||||
}
|
||||
|
||||
const content = await Bun.file(source.composePath).text();
|
||||
const content = readFileSync(source.composePath, 'utf-8');
|
||||
const stackDir = dirname(source.composePath);
|
||||
|
||||
// For custom paths, suggest .env next to compose if envPath not set
|
||||
@@ -420,15 +459,14 @@ export async function getStackComposeFile(
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
const actualComposePath = join(stackDir, fileName);
|
||||
const file = Bun.file(actualComposePath);
|
||||
if (await file.exists()) {
|
||||
if (existsSync(actualComposePath)) {
|
||||
// Check for .env file in the same directory
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
const envExists = existsSync(envFilePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
content: readFileSync(actualComposePath, 'utf-8'),
|
||||
stackDir,
|
||||
// Always return the actual resolved paths for display
|
||||
composePath: actualComposePath,
|
||||
@@ -632,7 +670,7 @@ export async function saveStackComposeFile(
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Bun.write(composePath, content);
|
||||
writeFileSync(composePath, content);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to save compose file: ${err.message}` };
|
||||
@@ -672,7 +710,7 @@ export async function saveStackComposeFile(
|
||||
}
|
||||
|
||||
try {
|
||||
await Bun.write(composeFile, content);
|
||||
writeFileSync(composeFile, content);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to ${create ? 'create' : 'save'} compose file: ${err.message}` };
|
||||
@@ -712,26 +750,23 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['docker', 'login', '-u', reg.username, '--password-stdin', registryHost],
|
||||
const proc = nodeSpawn(
|
||||
'docker', ['login', '-u', reg.username, '--password-stdin', registryHost],
|
||||
{
|
||||
env: spawnEnv,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
|
||||
// Write password to stdin (Bun's FileSink API)
|
||||
proc.stdin.write(reg.password);
|
||||
proc.stdin.end();
|
||||
// Write password to stdin
|
||||
proc.stdin!.write(reg.password);
|
||||
proc.stdin!.end();
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
const { exitCode, stderr } = await collectProcess(proc);
|
||||
|
||||
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) {
|
||||
@@ -786,7 +821,7 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a docker compose command locally via Bun.spawn.
|
||||
* Execute a docker compose command locally via child_process.spawn.
|
||||
*
|
||||
* @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files)
|
||||
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
|
||||
@@ -834,7 +869,7 @@ async function executeLocalCompose(
|
||||
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
composeFile = join(stackDir, 'compose.yaml');
|
||||
await Bun.write(composeFile, composeContent);
|
||||
writeFileSync(composeFile, composeContent);
|
||||
}
|
||||
|
||||
// Rewrite relative volume paths for host path translation (in memory only, not saved to disk)
|
||||
@@ -908,15 +943,15 @@ async function executeLocalCompose(
|
||||
// Write certs to files (docker-compose expects specific filenames)
|
||||
if (tlsConfig.ca) {
|
||||
const cleanedCa = cleanPem(tlsConfig.ca);
|
||||
if (cleanedCa) await Bun.write(join(tlsCertDir, 'ca.pem'), cleanedCa);
|
||||
if (cleanedCa) writeFileSync(join(tlsCertDir, 'ca.pem'), cleanedCa);
|
||||
}
|
||||
if (tlsConfig.cert) {
|
||||
const cleanedCert = cleanPem(tlsConfig.cert);
|
||||
if (cleanedCert) await Bun.write(join(tlsCertDir, 'cert.pem'), cleanedCert);
|
||||
if (cleanedCert) writeFileSync(join(tlsCertDir, 'cert.pem'), cleanedCert);
|
||||
}
|
||||
if (tlsConfig.key) {
|
||||
const cleanedKey = cleanPem(tlsConfig.key);
|
||||
if (cleanedKey) await Bun.write(join(tlsCertDir, 'key.pem'), cleanedKey);
|
||||
if (cleanedKey) writeFileSync(join(tlsCertDir, 'key.pem'), cleanedKey);
|
||||
}
|
||||
|
||||
// Set Docker TLS environment variables
|
||||
@@ -941,13 +976,13 @@ async function executeLocalCompose(
|
||||
// Also include override file if it exists (needs path translation too)
|
||||
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
|
||||
if (overrideFile) {
|
||||
let overrideContent = await Bun.file(overrideFile).text();
|
||||
let overrideContent = readFileSync(overrideFile, 'utf-8');
|
||||
if (getHostDataDir()) {
|
||||
const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir);
|
||||
if (rewrite.modified) overrideContent = rewrite.content;
|
||||
}
|
||||
tempOverridePath = join(stackDir, '.compose.override.translated.yaml');
|
||||
await Bun.write(tempOverridePath, overrideContent);
|
||||
writeFileSync(tempOverridePath, overrideContent);
|
||||
args.push('-f', tempOverridePath);
|
||||
console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`);
|
||||
}
|
||||
@@ -983,7 +1018,7 @@ async function executeLocalCompose(
|
||||
const overrideEnvPath = join(stackDir, '.env.dockhand');
|
||||
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
|
||||
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
|
||||
await Bun.write(overrideEnvPath, header + lines.join('\n') + '\n');
|
||||
writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n');
|
||||
args.push('--env-file', overrideEnvPath);
|
||||
}
|
||||
|
||||
@@ -1001,7 +1036,7 @@ async function executeLocalCompose(
|
||||
}
|
||||
break;
|
||||
case 'down':
|
||||
args.push('down');
|
||||
args.push('down', '--remove-orphans');
|
||||
if (removeVolumes) args.push('--volumes');
|
||||
break;
|
||||
case 'stop':
|
||||
@@ -1045,12 +1080,10 @@ async function executeLocalCompose(
|
||||
|
||||
try {
|
||||
console.log(`${logPrefix} Spawning docker compose process...`);
|
||||
const proc = Bun.spawn(args, {
|
||||
const proc = nodeSpawn(args[0], args.slice(1), {
|
||||
cwd: stackDir,
|
||||
env: spawnEnv,
|
||||
stdin: useStdin ? 'pipe' : 'inherit',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stdio: [useStdin ? 'pipe' : 'inherit', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// If using stdin (host path translation), write the modified compose content
|
||||
@@ -1077,12 +1110,7 @@ async function executeLocalCompose(
|
||||
}, COMPOSE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text()
|
||||
]);
|
||||
|
||||
const code = await proc.exited;
|
||||
const { exitCode: code, stdout, stderr } = await collectProcess(proc);
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`);
|
||||
@@ -1343,7 +1371,7 @@ async function executeComposeCommand(
|
||||
let hawserEnvVars = envVars;
|
||||
if (envPath && existsSync(envPath)) {
|
||||
try {
|
||||
const envFileContent = await Bun.file(envPath).text();
|
||||
const envFileContent = readFileSync(envPath, 'utf-8');
|
||||
const envFileVars = parseEnvFileContent(envFileContent, stackName);
|
||||
// Merge: envFileVars (lowest) < envVars (DB overrides)
|
||||
// secretVars are handled separately in executeComposeViaHawser
|
||||
@@ -1362,7 +1390,7 @@ async function executeComposeCommand(
|
||||
const overridePath = findComposeOverrideFile(composeDir, composeBaseName);
|
||||
if (overridePath) {
|
||||
try {
|
||||
const overrideContent = await Bun.file(overridePath).text();
|
||||
const overrideContent = readFileSync(overridePath, 'utf-8');
|
||||
hawserStackFiles = { ...(hawserStackFiles || {}), [basename(overridePath)]: overrideContent };
|
||||
console.log(`[Stack:${stackName}] Including override file for Hawser: ${basename(overridePath)}`);
|
||||
} catch (err) {
|
||||
@@ -1371,6 +1399,16 @@ async function executeComposeCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// For git stacks: generate .env.dockhand with non-secret DB overrides
|
||||
// This mirrors executeLocalCompose behavior (lines 1017-1023).
|
||||
// envVars contains only the DB overrides (not merged repo .env values from hawserEnvVars).
|
||||
if (useOverrideFile && envVars && Object.keys(envVars).length > 0) {
|
||||
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
|
||||
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
|
||||
hawserStackFiles = { ...(hawserStackFiles || {}), '.env.dockhand': header + lines.join('\n') + '\n' };
|
||||
console.log(`[Stack:${stackName}] Including .env.dockhand override file for Hawser (${Object.keys(envVars).length} vars)`);
|
||||
}
|
||||
|
||||
return executeComposeViaHawser(
|
||||
operation,
|
||||
stackName,
|
||||
@@ -1766,8 +1804,9 @@ async function requireComposeFile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stack using docker compose up
|
||||
* Falls back to individual container start for stacks without compose files
|
||||
* Start a stack using docker compose start (resumes stopped containers).
|
||||
* Falls back to docker compose up if containers don't exist (stack was removed/down).
|
||||
* Falls back to individual container start for stacks without compose files.
|
||||
*/
|
||||
export async function startStack(
|
||||
stackName: string,
|
||||
@@ -1780,9 +1819,17 @@ export async function startStack(
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
|
||||
const opts = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
|
||||
|
||||
// Check if containers exist for this stack. If they do, use 'start' to resume
|
||||
// them (preserves container IDs, avoids Traefik race conditions from recreation).
|
||||
// If no containers exist (stack was removed/down), use 'up' to create them.
|
||||
const containers = await getStackContainers(stackName, envId);
|
||||
const operation = containers.length > 0 ? 'start' : 'up';
|
||||
|
||||
return executeComposeCommand(
|
||||
'up',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
operation,
|
||||
opts,
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
@@ -2185,7 +2232,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
}
|
||||
if (actualEnvPath && existsSync(actualEnvPath) && !stackFiles['.env']) {
|
||||
try {
|
||||
const envContent = await Bun.file(actualEnvPath).text();
|
||||
const envContent = readFileSync(actualEnvPath, 'utf-8');
|
||||
stackFiles['.env'] = envContent;
|
||||
console.log(`${logPrefix} Added .env to stackFiles for Hawser (${envContent.length} chars)`);
|
||||
} catch (err) {
|
||||
@@ -2415,7 +2462,7 @@ export async function writeStackEnvFile(
|
||||
.map(v => `${v.key.trim()}=${v.value}`)
|
||||
.join('\n') + '\n';
|
||||
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
writeFileSync(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2453,7 +2500,7 @@ export async function writeRawStackEnvFile(
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
writeFileSync(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,687 +0,0 @@
|
||||
/**
|
||||
* Event Collection Subprocess
|
||||
*
|
||||
* Runs as a separate process via Bun.spawn to collect Docker container events
|
||||
* without blocking the main HTTP thread.
|
||||
*
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db';
|
||||
import { getDockerEvents } from '../docker';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
// Reconnection settings
|
||||
const RECONNECT_DELAY = 5000; // 5 seconds
|
||||
const MAX_RECONNECT_DELAY = 60000; // 1 minute max
|
||||
|
||||
// Track environment online status for notifications
|
||||
// Only send notifications on status CHANGES, not on every reconnect attempt
|
||||
const environmentOnlineStatus: Map<number, boolean> = new Map();
|
||||
|
||||
// Active collectors per environment (for streaming mode)
|
||||
const collectors: Map<number, { controller: AbortController; reconnectTimeout: ReturnType<typeof setTimeout> | null }> = 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
|
||||
const CACHE_CLEANUP_INTERVAL_MS = 5000; // Clean up cache every 5 seconds (match dedup window)
|
||||
const MAX_DEDUP_CACHE_SIZE = 500; // Hard limit to prevent unbounded growth
|
||||
|
||||
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',
|
||||
'start',
|
||||
'stop',
|
||||
'die',
|
||||
'kill',
|
||||
'restart',
|
||||
'pause',
|
||||
'unpause',
|
||||
'destroy',
|
||||
'rename',
|
||||
'update',
|
||||
'oom',
|
||||
'health_status'
|
||||
];
|
||||
|
||||
// Scanner image patterns to exclude from events
|
||||
const SCANNER_IMAGE_PATTERNS = [
|
||||
'anchore/grype',
|
||||
'aquasec/trivy',
|
||||
'ghcr.io/anchore/grype',
|
||||
'ghcr.io/aquasecurity/trivy'
|
||||
];
|
||||
|
||||
// Container name patterns to exclude from events
|
||||
const EXCLUDED_CONTAINER_PREFIXES = ['dockhand-browse-'];
|
||||
|
||||
/**
|
||||
* Send message to main process
|
||||
*/
|
||||
function send(message: any): void {
|
||||
if (process.send) {
|
||||
process.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isScannerContainer(image: string | null | undefined): boolean {
|
||||
if (!image) return false;
|
||||
const lowerImage = image.toLowerCase();
|
||||
return SCANNER_IMAGE_PATTERNS.some((pattern) => lowerImage.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
function isExcludedContainer(containerName: string | null | undefined): boolean {
|
||||
if (!containerName) return false;
|
||||
return EXCLUDED_CONTAINER_PREFIXES.some((prefix) => containerName.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update environment online status and notify main process on change
|
||||
*/
|
||||
function updateEnvironmentStatus(
|
||||
envId: number,
|
||||
envName: string,
|
||||
isOnline: boolean,
|
||||
errorMessage?: string
|
||||
) {
|
||||
const previousStatus = environmentOnlineStatus.get(envId);
|
||||
|
||||
// Only send notification on status CHANGE (not on first connection or repeated failures)
|
||||
if (previousStatus !== undefined && previousStatus !== isOnline) {
|
||||
send({
|
||||
type: 'env_status',
|
||||
envId,
|
||||
envName,
|
||||
online: isOnline,
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
environmentOnlineStatus.set(envId, isOnline);
|
||||
}
|
||||
|
||||
interface DockerEvent {
|
||||
Type: string;
|
||||
Action: string;
|
||||
Actor: {
|
||||
ID: string;
|
||||
Attributes: Record<string, string>;
|
||||
};
|
||||
time: number;
|
||||
timeNano: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old entries from the deduplication cache
|
||||
* Also enforces max size limit with LRU eviction
|
||||
*/
|
||||
function cleanupRecentEvents() {
|
||||
const now = Date.now();
|
||||
// First pass: remove expired entries
|
||||
for (const [key, timestamp] of recentEvents.entries()) {
|
||||
if (now - timestamp > DEDUP_WINDOW_MS) {
|
||||
recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
// Second pass: enforce max size with LRU eviction if still too large
|
||||
if (recentEvents.size > MAX_DEDUP_CACHE_SIZE) {
|
||||
const entries = Array.from(recentEvents.entries())
|
||||
.sort((a, b) => a[1] - b[1]); // Sort by timestamp (oldest first)
|
||||
const toRemove = entries.slice(0, entries.length - MAX_DEDUP_CACHE_SIZE);
|
||||
for (const [key] of toRemove) {
|
||||
recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Docker event
|
||||
*/
|
||||
function processEvent(event: DockerEvent, envId: number) {
|
||||
// Only process container events
|
||||
if (event.Type !== 'container') return;
|
||||
|
||||
// Map Docker action to our action type
|
||||
// For health_status events, Docker sends "health_status: unhealthy" or "health_status: healthy"
|
||||
// We need to preserve the full string for notifications to distinguish healthy vs unhealthy
|
||||
const rawAction = event.Action;
|
||||
const baseAction = rawAction.split(':')[0] as ContainerEventAction;
|
||||
|
||||
// Skip actions we don't care about
|
||||
if (!CONTAINER_ACTIONS.includes(baseAction)) return;
|
||||
|
||||
// For notifications, preserve full action for health_status to enable proper mapping
|
||||
const action = rawAction.startsWith('health_status') ? rawAction : baseAction;
|
||||
|
||||
const containerId = event.Actor?.ID;
|
||||
const containerName = event.Actor?.Attributes?.name;
|
||||
const image = event.Actor?.Attributes?.image;
|
||||
|
||||
if (!containerId) return;
|
||||
|
||||
// Skip scanner containers (Trivy, Grype)
|
||||
if (isScannerContainer(image)) return;
|
||||
|
||||
// Skip internal Dockhand containers (volume browser helpers)
|
||||
if (isExcludedContainer(containerName)) return;
|
||||
|
||||
// Deduplicate events
|
||||
const dedupKey = `${envId}-${event.timeNano}-${containerId}-${action}`;
|
||||
if (recentEvents.has(dedupKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
recentEvents.set(dedupKey, Date.now());
|
||||
|
||||
// Clean up if cache gets too large
|
||||
if (recentEvents.size > 200) {
|
||||
cleanupRecentEvents();
|
||||
}
|
||||
|
||||
// Convert Unix nanosecond timestamp to ISO string
|
||||
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
|
||||
|
||||
// Prepare notification data
|
||||
// For health_status events, create a cleaner label
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
// Send event to main process for DB save and SSE broadcast
|
||||
send({
|
||||
type: 'container_event',
|
||||
event: {
|
||||
environmentId: envId,
|
||||
containerId: containerId,
|
||||
containerName: containerName || null,
|
||||
image: image || null,
|
||||
action,
|
||||
actorAttributes: event.Actor?.Attributes || null,
|
||||
timestamp
|
||||
},
|
||||
notification: {
|
||||
action,
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
||||
notificationType,
|
||||
image
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
// Stop existing collector if any
|
||||
stopEnvironmentCollector(envId);
|
||||
|
||||
const controller = new AbortController();
|
||||
const entry = { controller, reconnectTimeout: null as ReturnType<typeof setTimeout> | null };
|
||||
collectors.set(envId, entry);
|
||||
|
||||
let reconnectDelay = RECONNECT_DELAY;
|
||||
|
||||
const connect = async () => {
|
||||
if (controller.signal.aborted || isShuttingDown) return;
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[EventSubprocess] Connecting to Docker events for ${envName} (env ${envId})...`
|
||||
);
|
||||
|
||||
const eventStream = await getDockerEvents({ type: ['container'] }, envId);
|
||||
|
||||
if (!eventStream) {
|
||||
console.error(`[EventSubprocess] Failed to get event stream for ${envName}`);
|
||||
updateEnvironmentStatus(envId, envName, false, 'Failed to connect to Docker');
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset reconnect delay on successful connection
|
||||
reconnectDelay = RECONNECT_DELAY;
|
||||
console.log(`[EventSubprocess] Connected to Docker events for ${envName}`);
|
||||
|
||||
updateEnvironmentStatus(envId, envName, true);
|
||||
|
||||
reader = eventStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (!controller.signal.aborted && !isShuttingDown) {
|
||||
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 for partial chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(`[EventSubprocess] Stream error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed - ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection closed, reconnect
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (reader) {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed - ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted && !isShuttingDown && error.name !== 'AbortError') {
|
||||
console.error(`[EventSubprocess] Connection error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (controller.signal.aborted || isShuttingDown) return;
|
||||
|
||||
console.log(`[EventSubprocess] Reconnecting to ${envName} in ${reconnectDelay / 1000}s...`);
|
||||
entry.reconnectTimeout = setTimeout(() => {
|
||||
entry.reconnectTimeout = null;
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
connect();
|
||||
}
|
||||
}, reconnectDelay);
|
||||
|
||||
// Exponential backoff
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
// Start the connection
|
||||
connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 entry = collectors.get(envId);
|
||||
if (entry) {
|
||||
if (entry.reconnectTimeout !== null) {
|
||||
clearTimeout(entry.reconnectTimeout);
|
||||
}
|
||||
entry.controller.abort();
|
||||
collectors.delete(envId);
|
||||
environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh collectors when environments change
|
||||
*/
|
||||
async function refreshEventCollectors() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
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(
|
||||
environments
|
||||
.filter((e) => e.collectActivity && e.connectionType !== 'hawser-edge')
|
||||
.map((e) => e.id)
|
||||
);
|
||||
|
||||
// Stop collectors for removed environments or those with collection disabled
|
||||
for (const envId of collectors.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`);
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale map entries for deleted environments
|
||||
const allEnvIds = new Set(environments.map((e) => e.id));
|
||||
for (const envId of environmentOnlineStatus.keys()) {
|
||||
if (!allEnvIds.has(envId)) environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
for (const envId of lastPollTime.keys()) {
|
||||
if (!allEnvIds.has(envId)) lastPollTime.delete(envId);
|
||||
}
|
||||
|
||||
// Start collectors based on mode
|
||||
for (const env of environments) {
|
||||
// Skip Hawser Edge (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') continue;
|
||||
|
||||
// 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) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[EventSubprocess] Failed to refresh collectors: ${message}`);
|
||||
send({ type: 'error', message: `Failed to refresh collectors: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle commands from main process
|
||||
*/
|
||||
function handleCommand(command: MainProcessCommand): void {
|
||||
switch (command.type) {
|
||||
case 'refresh_environments':
|
||||
console.log('[EventSubprocess] Refreshing environments...');
|
||||
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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
function shutdown(): void {
|
||||
isShuttingDown = true;
|
||||
|
||||
// Stop periodic cache cleanup
|
||||
if (cacheCleanupInterval) {
|
||||
clearInterval(cacheCleanupInterval);
|
||||
cacheCleanupInterval = null;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
console.log('[EventSubprocess] Stopped');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the event collector
|
||||
*/
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[EventSubprocess] Failed to load settings, using defaults: ${message}`);
|
||||
}
|
||||
|
||||
// Start collectors for all environments
|
||||
await refreshEventCollectors();
|
||||
|
||||
// Start periodic cache cleanup
|
||||
cacheCleanupInterval = setInterval(cleanupRecentEvents, CACHE_CLEANUP_INTERVAL_MS);
|
||||
console.log('[EventSubprocess] Started deduplication cache cleanup (every 5s)');
|
||||
|
||||
// Start memory diagnostics logging (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const mem = process.memoryUsage();
|
||||
console.log(
|
||||
`[EventSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` +
|
||||
`rss=${Math.round(mem.rss / 1024 / 1024)}MB, ` +
|
||||
`dedup=${recentEvents.size}, collectors=${collectors.size}, pollers=${pollIntervals.size}`
|
||||
);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Listen for commands from main process
|
||||
process.on('message', (message: MainProcessCommand) => {
|
||||
handleCommand(message);
|
||||
});
|
||||
|
||||
// Handle termination signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Signal ready
|
||||
send({ type: 'ready' });
|
||||
|
||||
console.log('[EventSubprocess] Started successfully');
|
||||
}
|
||||
|
||||
// Start the subprocess
|
||||
start();
|
||||
@@ -1,498 +0,0 @@
|
||||
/**
|
||||
* Metrics Collection Subprocess
|
||||
*
|
||||
* Runs as a separate process via Bun.spawn to collect CPU/memory metrics
|
||||
* and check disk space without blocking the main HTTP thread.
|
||||
*
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db';
|
||||
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker';
|
||||
import os from 'node:os';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
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
|
||||
const ENV_DISK_TIMEOUT = 20000; // 20 seconds timeout per environment for disk checks
|
||||
|
||||
/**
|
||||
* Timeout wrapper - returns fallback if promise takes too long
|
||||
* IMPORTANT: Properly clears the timeout to prevent memory leaks
|
||||
*/
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
let collectInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let diskCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
let collectionCycleCount = 0;
|
||||
const MEMORY_LOG_INTERVAL = 10; // Log memory every 10 cycles (~5 minutes at 30s interval)
|
||||
|
||||
/**
|
||||
* Send message to main process
|
||||
*/
|
||||
function send(message: any): void {
|
||||
if (process.send) {
|
||||
process.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics for a single environment
|
||||
*/
|
||||
async function collectEnvMetrics(env: { id: number; name: string; host?: string; socketPath?: string; collectMetrics?: boolean; connectionType?: string }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip Hawser Edge environments (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get running containers
|
||||
const containers = await listContainers(false, env.id); // Only running
|
||||
let totalCpuPercent = 0;
|
||||
let totalContainerMemUsed = 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 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 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 };
|
||||
} catch {
|
||||
return { cpuPercent: 0, memUsage: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
totalCpuPercent = statsResults.reduce((sum, r) => sum + r.cpuPercent, 0);
|
||||
totalContainerMemUsed = statsResults.reduce((sum, r) => sum + r.memUsage, 0);
|
||||
|
||||
// Get host memory info from Docker
|
||||
const info = (await getDockerInfo(env.id)) as any;
|
||||
const memTotal = info?.MemTotal || os.totalmem();
|
||||
|
||||
// Calculate memory: sum of all container memory vs host total
|
||||
const memUsed = totalContainerMemUsed;
|
||||
const memPercent = memTotal > 0 ? (memUsed / memTotal) * 100 : 0;
|
||||
|
||||
// Normalize CPU by number of cores from the Docker host
|
||||
const cpuCount = info?.NCPU || os.cpus().length;
|
||||
const normalizedCpu = totalCpuPercent / cpuCount;
|
||||
|
||||
// Validate values - skip if any are NaN, Infinity, or negative
|
||||
const finalCpu = Number.isFinite(normalizedCpu) && normalizedCpu >= 0 ? normalizedCpu : 0;
|
||||
const finalMemPercent = Number.isFinite(memPercent) && memPercent >= 0 ? memPercent : 0;
|
||||
const finalMemUsed = Number.isFinite(memUsed) && memUsed >= 0 ? memUsed : 0;
|
||||
const finalMemTotal = Number.isFinite(memTotal) && memTotal > 0 ? memTotal : 0;
|
||||
|
||||
// Only send if we have valid memory total (otherwise metrics are meaningless)
|
||||
if (finalMemTotal > 0) {
|
||||
send({
|
||||
type: 'metric',
|
||||
envId: env.id,
|
||||
cpu: finalCpu,
|
||||
memPercent: finalMemPercent,
|
||||
memUsed: finalMemUsed,
|
||||
memTotal: finalMemTotal
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails (might be offline)
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[MetricsSubprocess] Failed to collect metrics for ${env.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics for all environments
|
||||
*/
|
||||
async function collectMetrics() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
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 with per-environment timeouts
|
||||
// Use Promise.allSettled so one slow/failed env doesn't block others
|
||||
const results = await Promise.allSettled(
|
||||
enabledEnvs.map((env) =>
|
||||
withTimeout(
|
||||
collectEnvMetrics(env).then(() => env.name),
|
||||
ENV_METRICS_TIMEOUT,
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Log any environments that timed out
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value === null) {
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics timed out after ${ENV_METRICS_TIMEOUT}ms`);
|
||||
} else if (result.status === 'rejected') {
|
||||
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic memory logging for diagnostics
|
||||
collectionCycleCount++;
|
||||
if (collectionCycleCount % MEMORY_LOG_INTERVAL === 0) {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const rssMB = Math.round(memUsage.rss / 1024 / 1024);
|
||||
console.log(`[MetricsSubprocess] Memory: heap=${heapMB}MB, rss=${rssMB}MB (cycle ${collectionCycleCount})`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MetricsSubprocess] Metrics collection error: ${message}`);
|
||||
send({ type: 'error', message: `Metrics collection error: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for a single environment
|
||||
*/
|
||||
async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean; connectionType?: string }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip Hawser Edge environments (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if disk warnings are enabled for this environment
|
||||
const diskWarningEnabled = (await getEnvSetting('disk_warning_enabled', env.id)) ?? true;
|
||||
if (!diskWarningEnabled) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine warning mode
|
||||
const diskWarningMode = (await getEnvSetting('disk_warning_mode', env.id)) ?? 'percentage';
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
if (diskWarningMode === 'absolute') {
|
||||
// Absolute mode: warn when usage exceeds GB threshold
|
||||
const thresholdGb = (await getEnvSetting('disk_warning_threshold_gb', env.id)) ?? 50;
|
||||
if (totalUsed > thresholdGb * GB) {
|
||||
send({
|
||||
type: 'disk_warning',
|
||||
envId: env.id,
|
||||
envName: env.name,
|
||||
message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space (threshold: ${thresholdGb} GB)`
|
||||
});
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
} else {
|
||||
// Percentage mode: need total disk space
|
||||
if (dataSpaceTotal > 0) {
|
||||
diskPercentUsed = (totalUsed / dataSpaceTotal) * 100;
|
||||
} else {
|
||||
// Can't determine percentage without total space — skip
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
(await getEnvSetting('disk_warning_threshold', env.id)) || DEFAULT_DISK_THRESHOLD;
|
||||
if (diskPercentUsed >= threshold) {
|
||||
console.log(
|
||||
`[MetricsSubprocess] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`
|
||||
);
|
||||
|
||||
send({
|
||||
type: 'disk_warning',
|
||||
envId: env.id,
|
||||
envName: env.name,
|
||||
message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`,
|
||||
diskPercent: diskPercentUsed
|
||||
});
|
||||
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[MetricsSubprocess] Failed to check disk space for ${env.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for all environments
|
||||
*/
|
||||
async function checkDiskSpace() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
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 with per-environment timeouts
|
||||
// Use Promise.allSettled so one slow/failed env doesn't block others
|
||||
const results = await Promise.allSettled(
|
||||
enabledEnvs.map((env) =>
|
||||
withTimeout(
|
||||
checkEnvDiskSpace(env).then(() => env.name),
|
||||
ENV_DISK_TIMEOUT,
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Log any environments that timed out
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value === null) {
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check timed out after ${ENV_DISK_TIMEOUT}ms`);
|
||||
} else if (result.status === 'rejected') {
|
||||
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up stale lastDiskWarning entries for deleted environments
|
||||
const activeEnvIds = new Set(environments.map((e) => e.id));
|
||||
for (const envId of lastDiskWarning.keys()) {
|
||||
if (!activeEnvIds.has(envId)) lastDiskWarning.delete(envId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MetricsSubprocess] Disk space check error: ${message}`);
|
||||
send({ type: 'error', message: `Disk space check error: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle commands from main process
|
||||
*/
|
||||
function handleCommand(command: MainProcessCommand): void {
|
||||
switch (command.type) {
|
||||
case 'refresh_environments':
|
||||
console.log('[MetricsSubprocess] Refreshing environments...');
|
||||
// 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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
function shutdown(): void {
|
||||
isShuttingDown = true;
|
||||
|
||||
if (collectInterval) {
|
||||
clearInterval(collectInterval);
|
||||
collectInterval = null;
|
||||
}
|
||||
if (diskCheckInterval) {
|
||||
clearInterval(diskCheckInterval);
|
||||
diskCheckInterval = null;
|
||||
}
|
||||
|
||||
lastDiskWarning.clear();
|
||||
console.log('[MetricsSubprocess] Stopped');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metrics collector
|
||||
*/
|
||||
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();
|
||||
|
||||
// Schedule regular collection
|
||||
collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
|
||||
// Start disk space checking (every 5 minutes) - can be disabled for Synology NAS
|
||||
const skipDfCollection = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
if (!skipDfCollection) {
|
||||
console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)');
|
||||
checkDiskSpace(); // Initial check
|
||||
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
|
||||
} else {
|
||||
console.log('[MetricsSubprocess] Disk space monitoring disabled (SKIP_DF_COLLECTION=true)');
|
||||
}
|
||||
|
||||
// Start memory diagnostics logging (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const mem = process.memoryUsage();
|
||||
console.log(
|
||||
`[MetricsSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` +
|
||||
`rss=${Math.round(mem.rss / 1024 / 1024)}MB`
|
||||
);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Listen for commands from main process
|
||||
process.on('message', (message: MainProcessCommand) => {
|
||||
handleCommand(message);
|
||||
});
|
||||
|
||||
// Handle termination signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Signal ready
|
||||
send({ type: 'ready' });
|
||||
|
||||
console.log('[MetricsSubprocess] Started successfully');
|
||||
}
|
||||
|
||||
// Start the subprocess
|
||||
start();
|
||||
Reference in new issue
Block a user