1.0.11
This commit is contained in:
1 parent
fe48d63164
commit
a3cc26d958
35 files changed
+2045
-273
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dockhand",
|
"name": "dockhand",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.10",
|
"version": "1.0.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bunx --bun vite dev",
|
"dev": "bunx --bun vite dev",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { checkLicenseExpiry, getHostname } from '$lib/server/license';
|
|||||||
import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
||||||
import { detectHostDataDir } from '$lib/server/host-path';
|
import { detectHostDataDir } from '$lib/server/host-path';
|
||||||
import { listContainers, removeContainer } from '$lib/server/docker';
|
import { listContainers, removeContainer } from '$lib/server/docker';
|
||||||
|
import { migrateCredentials } from '$lib/server/encryption';
|
||||||
import { rmSync, readdirSync, existsSync } from 'fs';
|
import { rmSync, readdirSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||||
@@ -69,6 +70,13 @@ if (!initialized) {
|
|||||||
|
|
||||||
setServerStartTime(); // Track when server started
|
setServerStartTime(); // Track when server started
|
||||||
initDatabase();
|
initDatabase();
|
||||||
|
|
||||||
|
// Migrate plain text credentials to encrypted storage
|
||||||
|
// This also handles key rotation if ENCRYPTION_KEY env var differs from key file
|
||||||
|
migrateCredentials().catch(err => {
|
||||||
|
console.error('[Startup] Failed to migrate credentials:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
|
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
|
||||||
console.log('Hostname for license validation:', getHostname());
|
console.log('Hostname for license validation:', getHostname());
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,13 @@
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Skip commented lines (YAML comments start with #)
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('#')) {
|
||||||
|
pos += line.length + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this line contains any of our marked variables
|
// Check if this line contains any of our marked variables
|
||||||
for (const marker of markers) {
|
for (const marker of markers) {
|
||||||
// Match ${VAR_NAME} or ${VAR_NAME:-...} patterns
|
// Match ${VAR_NAME} or ${VAR_NAME:-...} patterns
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "1.0.11",
|
||||||
|
"date": "2026-01-20",
|
||||||
|
"changes": [
|
||||||
|
{ "type": "fix", "text": "Encryption at rest for sensitive credentials (AES-256-GCM)" },
|
||||||
|
{ "type": "fix", "text": "Fix registry browsing and image push for registries with organization paths (e.g., registry.example.com/org)" },
|
||||||
|
{ "type": "fix", "text": "Fix security scan failing to parse scanner output" },
|
||||||
|
{ "type": "fix", "text": "Fix git sync stuck with sync_status set to running if app restarted during stack sync" },
|
||||||
|
{ "type": "fix", "text": "Fix updating via containers tab doesn't properly restart the container" }
|
||||||
|
],
|
||||||
|
"imageTag": "fnsys/dockhand:v1.0.11"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"date": "2026-01-18",
|
"date": "2026-01-18",
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export async function audit(
|
|||||||
await logAuditEvent(data);
|
await logAuditEvent(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't let audit logging errors break the main operation
|
// Don't let audit logging errors break the main operation
|
||||||
console.error('Failed to log audit event:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Audit] Failed to log event:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +303,7 @@ export async function auditAuth(
|
|||||||
try {
|
try {
|
||||||
await logAuditEvent(data);
|
await logAuditEvent(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to log audit event:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Audit] Failed to log event:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,7 +704,8 @@ async function tryLdapAuth(
|
|||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
try { await client.unbind(); } catch {}
|
try { await client.unbind(); } catch {}
|
||||||
console.error('LDAP authentication error:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[LDAP] Authentication error:', errorMsg);
|
||||||
return { success: false, error: 'LDAP authentication failed' };
|
return { success: false, error: 'LDAP authentication failed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -766,7 +767,8 @@ async function checkLdapGroupMembership(
|
|||||||
await client.unbind();
|
await client.unbind();
|
||||||
return searchEntries.length > 0;
|
return searchEntries.length > 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('LDAP group membership check failed:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[LDAP] Group membership check failed:', errorMsg);
|
||||||
try { await client.unbind(); } catch {}
|
try { await client.unbind(); } catch {}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1214,7 +1216,8 @@ export async function buildOidcAuthorizationUrl(
|
|||||||
const authUrl = `${discovery.authorization_endpoint}?${params.toString()}`;
|
const authUrl = `${discovery.authorization_endpoint}?${params.toString()}`;
|
||||||
return { url: authUrl, state };
|
return { url: authUrl, state };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to build OIDC authorization URL:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[OIDC] Failed to build authorization URL:', errorMsg);
|
||||||
return { error: error.message || 'Failed to initialize SSO' };
|
return { error: error.message || 'Failed to initialize SSO' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1415,7 +1418,8 @@ export async function handleOidcCallback(
|
|||||||
providerName: config.name
|
providerName: config.name
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('OIDC callback error:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[OIDC] Callback error:', errorMsg);
|
||||||
return { success: false, error: error.message || 'SSO authentication failed' };
|
return { success: false, error: error.message || 'SSO authentication failed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ export function initCryptoFallback(): boolean {
|
|||||||
}
|
}
|
||||||
console.log('[Crypto] /dev/urandom fallback initialized successfully');
|
console.log('[Crypto] /dev/urandom fallback initialized successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Crypto] FATAL: Failed to read from /dev/urandom:', err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[Crypto] FATAL: Failed to read from /dev/urandom:', errorMsg);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+124
-50
@@ -78,6 +78,7 @@ import {
|
|||||||
} from './db/drizzle.js';
|
} from './db/drizzle.js';
|
||||||
|
|
||||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||||
|
import { encrypt, decrypt } from './encryption.js';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export { db, isPostgres, isSqlite };
|
export { db, isPostgres, isSqlite };
|
||||||
@@ -112,7 +113,12 @@ export function initDatabase() {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export async function getEnvironments(): Promise<Environment[]> {
|
export async function getEnvironments(): Promise<Environment[]> {
|
||||||
return db.select().from(environments).orderBy(asc(environments.name));
|
const results = await db.select().from(environments).orderBy(asc(environments.name));
|
||||||
|
return results.map((e: Environment) => ({
|
||||||
|
...e,
|
||||||
|
tlsKey: decrypt(e.tlsKey),
|
||||||
|
hawserToken: decrypt(e.hawserToken)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasEnvironments(): Promise<boolean> {
|
export async function hasEnvironments(): Promise<boolean> {
|
||||||
@@ -122,12 +128,22 @@ export async function hasEnvironments(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function getEnvironment(id: number): Promise<Environment | undefined> {
|
export async function getEnvironment(id: number): Promise<Environment | undefined> {
|
||||||
const results = await db.select().from(environments).where(eq(environments.id, id));
|
const results = await db.select().from(environments).where(eq(environments.id, id));
|
||||||
return results[0];
|
if (!results[0]) return undefined;
|
||||||
|
return {
|
||||||
|
...results[0],
|
||||||
|
tlsKey: decrypt(results[0].tlsKey),
|
||||||
|
hawserToken: decrypt(results[0].hawserToken)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnvironmentByName(name: string): Promise<Environment | undefined> {
|
export async function getEnvironmentByName(name: string): Promise<Environment | undefined> {
|
||||||
const results = await db.select().from(environments).where(eq(environments.name, name));
|
const results = await db.select().from(environments).where(eq(environments.name, name));
|
||||||
return results[0];
|
if (!results[0]) return undefined;
|
||||||
|
return {
|
||||||
|
...results[0],
|
||||||
|
tlsKey: decrypt(results[0].tlsKey),
|
||||||
|
hawserToken: decrypt(results[0].hawserToken)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
|
export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
|
||||||
@@ -138,7 +154,7 @@ export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt
|
|||||||
protocol: env.protocol || 'http',
|
protocol: env.protocol || 'http',
|
||||||
tlsCa: env.tlsCa || null,
|
tlsCa: env.tlsCa || null,
|
||||||
tlsCert: env.tlsCert || null,
|
tlsCert: env.tlsCert || null,
|
||||||
tlsKey: env.tlsKey || null,
|
tlsKey: encrypt(env.tlsKey) || null,
|
||||||
icon: env.icon || 'globe',
|
icon: env.icon || 'globe',
|
||||||
socketPath: env.socketPath || '/var/run/docker.sock',
|
socketPath: env.socketPath || '/var/run/docker.sock',
|
||||||
collectActivity: env.collectActivity !== false,
|
collectActivity: env.collectActivity !== false,
|
||||||
@@ -146,9 +162,13 @@ export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt
|
|||||||
highlightChanges: env.highlightChanges !== false,
|
highlightChanges: env.highlightChanges !== false,
|
||||||
labels: env.labels || null,
|
labels: env.labels || null,
|
||||||
connectionType: env.connectionType || 'socket',
|
connectionType: env.connectionType || 'socket',
|
||||||
hawserToken: env.hawserToken || null
|
hawserToken: encrypt(env.hawserToken) || null
|
||||||
}).returning();
|
}).returning();
|
||||||
return result[0];
|
return {
|
||||||
|
...result[0],
|
||||||
|
tlsKey: decrypt(result[0].tlsKey),
|
||||||
|
hawserToken: decrypt(result[0].hawserToken)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEnvironment(id: number, env: Partial<Environment>): Promise<Environment | undefined> {
|
export async function updateEnvironment(id: number, env: Partial<Environment>): Promise<Environment | undefined> {
|
||||||
@@ -160,7 +180,7 @@ export async function updateEnvironment(id: number, env: Partial<Environment>):
|
|||||||
if (env.protocol !== undefined) updateData.protocol = env.protocol;
|
if (env.protocol !== undefined) updateData.protocol = env.protocol;
|
||||||
if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa;
|
if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa;
|
||||||
if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert;
|
if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert;
|
||||||
if (env.tlsKey !== undefined) updateData.tlsKey = env.tlsKey;
|
if (env.tlsKey !== undefined) updateData.tlsKey = encrypt(env.tlsKey);
|
||||||
if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify;
|
if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify;
|
||||||
if (env.icon !== undefined) updateData.icon = env.icon;
|
if (env.icon !== undefined) updateData.icon = env.icon;
|
||||||
if (env.socketPath !== undefined) updateData.socketPath = env.socketPath;
|
if (env.socketPath !== undefined) updateData.socketPath = env.socketPath;
|
||||||
@@ -169,7 +189,7 @@ export async function updateEnvironment(id: number, env: Partial<Environment>):
|
|||||||
if (env.highlightChanges !== undefined) updateData.highlightChanges = env.highlightChanges;
|
if (env.highlightChanges !== undefined) updateData.highlightChanges = env.highlightChanges;
|
||||||
if (env.labels !== undefined) updateData.labels = env.labels;
|
if (env.labels !== undefined) updateData.labels = env.labels;
|
||||||
if (env.connectionType !== undefined) updateData.connectionType = env.connectionType;
|
if (env.connectionType !== undefined) updateData.connectionType = env.connectionType;
|
||||||
if (env.hawserToken !== undefined) updateData.hawserToken = env.hawserToken;
|
if (env.hawserToken !== undefined) updateData.hawserToken = encrypt(env.hawserToken);
|
||||||
|
|
||||||
await db.update(environments).set(updateData).where(eq(environments.id, id));
|
await db.update(environments).set(updateData).where(eq(environments.id, id));
|
||||||
return getEnvironment(id);
|
return getEnvironment(id);
|
||||||
@@ -183,19 +203,22 @@ export async function deleteEnvironment(id: number): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id));
|
await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cleanup host metrics for environment:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[DB] Failed to cleanup host metrics for environment:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.delete(stackEvents).where(eq(stackEvents.environmentId, id));
|
await db.delete(stackEvents).where(eq(stackEvents.environmentId, id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cleanup stack events for environment:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[DB] Failed to cleanup stack events for environment:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.delete(autoUpdateSettings).where(eq(autoUpdateSettings.environmentId, id));
|
await db.delete(autoUpdateSettings).where(eq(autoUpdateSettings.environmentId, id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cleanup auto-update schedules for environment:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[DB] Failed to cleanup auto-update schedules for environment:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(environments).where(eq(environments.id, id));
|
await db.delete(environments).where(eq(environments.id, id));
|
||||||
@@ -207,17 +230,20 @@ export async function deleteEnvironment(id: number): Promise<boolean> {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export async function getRegistries(): Promise<Registry[]> {
|
export async function getRegistries(): Promise<Registry[]> {
|
||||||
return db.select().from(registries).orderBy(desc(registries.isDefault), asc(registries.name));
|
const results = await db.select().from(registries).orderBy(desc(registries.isDefault), asc(registries.name));
|
||||||
|
return results.map((r: Registry) => ({ ...r, password: decrypt(r.password) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRegistry(id: number): Promise<Registry | undefined> {
|
export async function getRegistry(id: number): Promise<Registry | undefined> {
|
||||||
const results = await db.select().from(registries).where(eq(registries.id, id));
|
const results = await db.select().from(registries).where(eq(registries.id, id));
|
||||||
return results[0];
|
if (!results[0]) return undefined;
|
||||||
|
return { ...results[0], password: decrypt(results[0].password) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDefaultRegistry(): Promise<Registry | undefined> {
|
export async function getDefaultRegistry(): Promise<Registry | undefined> {
|
||||||
const results = await db.select().from(registries).where(eq(registries.isDefault, true));
|
const results = await db.select().from(registries).where(eq(registries.isDefault, true));
|
||||||
return results[0];
|
if (!results[0]) return undefined;
|
||||||
|
return { ...results[0], password: decrypt(results[0].password) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRegistry(registry: Omit<Registry, 'id' | 'createdAt' | 'updatedAt'>): Promise<Registry> {
|
export async function createRegistry(registry: Omit<Registry, 'id' | 'createdAt' | 'updatedAt'>): Promise<Registry> {
|
||||||
@@ -225,10 +251,13 @@ export async function createRegistry(registry: Omit<Registry, 'id' | 'createdAt'
|
|||||||
name: registry.name,
|
name: registry.name,
|
||||||
url: registry.url,
|
url: registry.url,
|
||||||
username: registry.username || null,
|
username: registry.username || null,
|
||||||
password: registry.password || null,
|
password: encrypt(registry.password) || null,
|
||||||
isDefault: registry.isDefault || false
|
isDefault: registry.isDefault || false
|
||||||
}).returning();
|
}).returning();
|
||||||
return result[0];
|
return {
|
||||||
|
...result[0],
|
||||||
|
password: decrypt(result[0].password)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRegistry(id: number, registry: Partial<Registry>): Promise<Registry | undefined> {
|
export async function updateRegistry(id: number, registry: Partial<Registry>): Promise<Registry | undefined> {
|
||||||
@@ -237,7 +266,7 @@ export async function updateRegistry(id: number, registry: Partial<Registry>): P
|
|||||||
if (registry.name !== undefined) updateData.name = registry.name;
|
if (registry.name !== undefined) updateData.name = registry.name;
|
||||||
if (registry.url !== undefined) updateData.url = registry.url;
|
if (registry.url !== undefined) updateData.url = registry.url;
|
||||||
if (registry.username !== undefined) updateData.username = registry.username || null;
|
if (registry.username !== undefined) updateData.username = registry.username || null;
|
||||||
if (registry.password !== undefined) updateData.password = registry.password || null;
|
if (registry.password !== undefined) updateData.password = encrypt(registry.password) || null;
|
||||||
if (registry.isDefault !== undefined) updateData.isDefault = registry.isDefault;
|
if (registry.isDefault !== undefined) updateData.isDefault = registry.isDefault;
|
||||||
|
|
||||||
await db.update(registries).set(updateData).where(eq(registries.id, id));
|
await db.update(registries).set(updateData).where(eq(registries.id, id));
|
||||||
@@ -474,7 +503,7 @@ export interface ConfigSetData {
|
|||||||
|
|
||||||
export async function getConfigSets(): Promise<ConfigSetData[]> {
|
export async function getConfigSets(): Promise<ConfigSetData[]> {
|
||||||
const rows = await db.select().from(configSets).orderBy(asc(configSets.name));
|
const rows = await db.select().from(configSets).orderBy(asc(configSets.name));
|
||||||
return rows.map(row => ({
|
return rows.map((row: typeof configSets.$inferSelect) => ({
|
||||||
...row,
|
...row,
|
||||||
envVars: row.envVars ? JSON.parse(row.envVars) : [],
|
envVars: row.envVars ? JSON.parse(row.envVars) : [],
|
||||||
labels: row.labels ? JSON.parse(row.labels) : [],
|
labels: row.labels ? JSON.parse(row.labels) : [],
|
||||||
@@ -821,11 +850,35 @@ export interface AppriseConfig {
|
|||||||
urls: string[];
|
urls: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to encrypt sensitive fields in notification config
|
||||||
|
function encryptNotificationConfig(type: 'smtp' | 'apprise', config: SmtpConfig | AppriseConfig): string {
|
||||||
|
if (type === 'smtp') {
|
||||||
|
const smtpConfig = config as SmtpConfig;
|
||||||
|
return JSON.stringify({
|
||||||
|
...smtpConfig,
|
||||||
|
password: encrypt(smtpConfig.password)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to decrypt sensitive fields in notification config
|
||||||
|
function decryptNotificationConfig(type: string, configJson: string): any {
|
||||||
|
const config = JSON.parse(configJson);
|
||||||
|
if (type === 'smtp' && config.password) {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
password: decrypt(config.password)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNotificationSettings(): Promise<NotificationSettingData[]> {
|
export async function getNotificationSettings(): Promise<NotificationSettingData[]> {
|
||||||
const rows = await db.select().from(notificationSettings).orderBy(desc(notificationSettings.createdAt));
|
const rows = await db.select().from(notificationSettings).orderBy(desc(notificationSettings.createdAt));
|
||||||
return rows.map(row => ({
|
return rows.map((row: typeof notificationSettings.$inferSelect) => ({
|
||||||
...row,
|
...row,
|
||||||
config: JSON.parse(row.config),
|
config: decryptNotificationConfig(row.type, row.config),
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||||
})) as NotificationSettingData[];
|
})) as NotificationSettingData[];
|
||||||
}
|
}
|
||||||
@@ -836,16 +889,16 @@ export async function getNotificationSetting(id: number): Promise<NotificationSe
|
|||||||
const row = results[0];
|
const row = results[0];
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
config: JSON.parse(row.config),
|
config: decryptNotificationConfig(row.type, row.config),
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||||
} as NotificationSettingData;
|
} as NotificationSettingData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnabledNotificationSettings(): Promise<NotificationSettingData[]> {
|
export async function getEnabledNotificationSettings(): Promise<NotificationSettingData[]> {
|
||||||
const rows = await db.select().from(notificationSettings).where(eq(notificationSettings.enabled, true));
|
const rows = await db.select().from(notificationSettings).where(eq(notificationSettings.enabled, true));
|
||||||
return rows.map(row => ({
|
return rows.map((row: typeof notificationSettings.$inferSelect) => ({
|
||||||
...row,
|
...row,
|
||||||
config: JSON.parse(row.config),
|
config: decryptNotificationConfig(row.type, row.config),
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||||
})) as NotificationSettingData[];
|
})) as NotificationSettingData[];
|
||||||
}
|
}
|
||||||
@@ -862,7 +915,7 @@ export async function createNotificationSetting(data: {
|
|||||||
type: data.type,
|
type: data.type,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
enabled: data.enabled !== false,
|
enabled: data.enabled !== false,
|
||||||
config: JSON.stringify(data.config),
|
config: encryptNotificationConfig(data.type, data.config),
|
||||||
eventTypes: JSON.stringify(eventTypes)
|
eventTypes: JSON.stringify(eventTypes)
|
||||||
}).returning();
|
}).returning();
|
||||||
return getNotificationSetting(result[0].id) as Promise<NotificationSettingData>;
|
return getNotificationSetting(result[0].id) as Promise<NotificationSettingData>;
|
||||||
@@ -881,7 +934,7 @@ export async function updateNotificationSetting(id: number, data: {
|
|||||||
|
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.config !== undefined) updateData.config = JSON.stringify(data.config);
|
if (data.config !== undefined) updateData.config = encryptNotificationConfig(existing.type, data.config);
|
||||||
if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes);
|
if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes);
|
||||||
|
|
||||||
await db.update(notificationSettings).set(updateData).where(eq(notificationSettings.id, id));
|
await db.update(notificationSettings).set(updateData).where(eq(notificationSettings.id, id));
|
||||||
@@ -931,7 +984,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
|
|||||||
.where(eq(environmentNotifications.environmentId, environmentId))
|
.where(eq(environmentNotifications.environmentId, environmentId))
|
||||||
.orderBy(asc(notificationSettings.name));
|
.orderBy(asc(notificationSettings.name));
|
||||||
|
|
||||||
return rows.map(row => ({
|
return rows.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||||
})) as EnvironmentNotificationData[];
|
})) as EnvironmentNotificationData[];
|
||||||
@@ -1039,7 +1092,7 @@ export async function getEnabledEnvironmentNotifications(
|
|||||||
.map(row => ({
|
.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
|
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
|
||||||
config: JSON.parse(row.config)
|
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
|
||||||
}))
|
}))
|
||||||
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
|
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
|
||||||
}
|
}
|
||||||
@@ -1591,6 +1644,7 @@ export async function getLdapConfigs(): Promise<LdapConfigData[]> {
|
|||||||
const results = await db.select().from(ldapConfig).orderBy(asc(ldapConfig.name));
|
const results = await db.select().from(ldapConfig).orderBy(asc(ldapConfig.name));
|
||||||
return results.map((row: any) => ({
|
return results.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
|
bindPassword: decrypt(row.bindPassword),
|
||||||
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null
|
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null
|
||||||
})) as LdapConfigData[];
|
})) as LdapConfigData[];
|
||||||
}
|
}
|
||||||
@@ -1601,6 +1655,7 @@ export async function getLdapConfig(id: number): Promise<LdapConfigData | null>
|
|||||||
const row = results[0] as any;
|
const row = results[0] as any;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
bindPassword: decrypt(row.bindPassword),
|
||||||
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null
|
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null
|
||||||
} as LdapConfigData;
|
} as LdapConfigData;
|
||||||
}
|
}
|
||||||
@@ -1611,7 +1666,7 @@ export async function createLdapConfig(data: Omit<LdapConfigData, 'id' | 'create
|
|||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
bindDn: data.bindDn || null,
|
bindDn: data.bindDn || null,
|
||||||
bindPassword: data.bindPassword || null,
|
bindPassword: encrypt(data.bindPassword) || null,
|
||||||
baseDn: data.baseDn,
|
baseDn: data.baseDn,
|
||||||
userFilter: data.userFilter,
|
userFilter: data.userFilter,
|
||||||
usernameAttribute: data.usernameAttribute,
|
usernameAttribute: data.usernameAttribute,
|
||||||
@@ -1634,7 +1689,7 @@ export async function updateLdapConfig(id: number, data: Partial<LdapConfigData>
|
|||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl;
|
if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl;
|
||||||
if (data.bindDn !== undefined) updateData.bindDn = data.bindDn || null;
|
if (data.bindDn !== undefined) updateData.bindDn = data.bindDn || null;
|
||||||
if (data.bindPassword !== undefined) updateData.bindPassword = data.bindPassword || null;
|
if (data.bindPassword !== undefined) updateData.bindPassword = encrypt(data.bindPassword) || null;
|
||||||
if (data.baseDn !== undefined) updateData.baseDn = data.baseDn;
|
if (data.baseDn !== undefined) updateData.baseDn = data.baseDn;
|
||||||
if (data.userFilter !== undefined) updateData.userFilter = data.userFilter;
|
if (data.userFilter !== undefined) updateData.userFilter = data.userFilter;
|
||||||
if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute;
|
if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute;
|
||||||
@@ -1689,6 +1744,7 @@ export async function getOidcConfigs(): Promise<OidcConfigData[]> {
|
|||||||
const rows = await db.select().from(oidcConfig).orderBy(asc(oidcConfig.name));
|
const rows = await db.select().from(oidcConfig).orderBy(asc(oidcConfig.name));
|
||||||
return rows.map(row => ({
|
return rows.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
|
clientSecret: decrypt(row.clientSecret) ?? '',
|
||||||
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : undefined
|
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : undefined
|
||||||
})) as OidcConfigData[];
|
})) as OidcConfigData[];
|
||||||
}
|
}
|
||||||
@@ -1698,6 +1754,7 @@ export async function getOidcConfig(id: number): Promise<OidcConfigData | null>
|
|||||||
if (!results[0]) return null;
|
if (!results[0]) return null;
|
||||||
return {
|
return {
|
||||||
...results[0],
|
...results[0],
|
||||||
|
clientSecret: decrypt(results[0].clientSecret) ?? '',
|
||||||
roleMappings: results[0].roleMappings ? JSON.parse(results[0].roleMappings) : undefined
|
roleMappings: results[0].roleMappings ? JSON.parse(results[0].roleMappings) : undefined
|
||||||
} as OidcConfigData;
|
} as OidcConfigData;
|
||||||
}
|
}
|
||||||
@@ -1708,7 +1765,7 @@ export async function createOidcConfig(data: Omit<OidcConfigData, 'id' | 'create
|
|||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
issuerUrl: data.issuerUrl,
|
issuerUrl: data.issuerUrl,
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
clientSecret: data.clientSecret,
|
clientSecret: encrypt(data.clientSecret) ?? '',
|
||||||
redirectUri: data.redirectUri,
|
redirectUri: data.redirectUri,
|
||||||
scopes: data.scopes,
|
scopes: data.scopes,
|
||||||
usernameClaim: data.usernameClaim,
|
usernameClaim: data.usernameClaim,
|
||||||
@@ -1729,7 +1786,7 @@ export async function updateOidcConfig(id: number, data: Partial<OidcConfigData>
|
|||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl;
|
if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl;
|
||||||
if (data.clientId !== undefined) updateData.clientId = data.clientId;
|
if (data.clientId !== undefined) updateData.clientId = data.clientId;
|
||||||
if (data.clientSecret !== undefined) updateData.clientSecret = data.clientSecret;
|
if (data.clientSecret !== undefined) updateData.clientSecret = encrypt(data.clientSecret);
|
||||||
if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri;
|
if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri;
|
||||||
if (data.scopes !== undefined) updateData.scopes = data.scopes;
|
if (data.scopes !== undefined) updateData.scopes = data.scopes;
|
||||||
if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim;
|
if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim;
|
||||||
@@ -1768,12 +1825,24 @@ export interface GitCredentialData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getGitCredentials(): Promise<GitCredentialData[]> {
|
export async function getGitCredentials(): Promise<GitCredentialData[]> {
|
||||||
return db.select().from(gitCredentials).orderBy(asc(gitCredentials.name)) as Promise<GitCredentialData[]>;
|
const results = await db.select().from(gitCredentials).orderBy(asc(gitCredentials.name));
|
||||||
|
return results.map(r => ({
|
||||||
|
...r,
|
||||||
|
password: decrypt(r.password),
|
||||||
|
sshPrivateKey: decrypt(r.sshPrivateKey),
|
||||||
|
sshPassphrase: decrypt(r.sshPassphrase)
|
||||||
|
})) as GitCredentialData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGitCredential(id: number): Promise<GitCredentialData | null> {
|
export async function getGitCredential(id: number): Promise<GitCredentialData | null> {
|
||||||
const results = await db.select().from(gitCredentials).where(eq(gitCredentials.id, id));
|
const results = await db.select().from(gitCredentials).where(eq(gitCredentials.id, id));
|
||||||
return results[0] as GitCredentialData || null;
|
if (!results[0]) return null;
|
||||||
|
return {
|
||||||
|
...results[0],
|
||||||
|
password: decrypt(results[0].password),
|
||||||
|
sshPrivateKey: decrypt(results[0].sshPrivateKey),
|
||||||
|
sshPassphrase: decrypt(results[0].sshPassphrase)
|
||||||
|
} as GitCredentialData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGitCredential(data: {
|
export async function createGitCredential(data: {
|
||||||
@@ -1788,9 +1857,9 @@ export async function createGitCredential(data: {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
authType: data.authType,
|
authType: data.authType,
|
||||||
username: data.username || null,
|
username: data.username || null,
|
||||||
password: data.password || null,
|
password: encrypt(data.password) || null,
|
||||||
sshPrivateKey: data.sshPrivateKey || null,
|
sshPrivateKey: encrypt(data.sshPrivateKey) || null,
|
||||||
sshPassphrase: data.sshPassphrase || null
|
sshPassphrase: encrypt(data.sshPassphrase) || null
|
||||||
}).returning();
|
}).returning();
|
||||||
return getGitCredential(result[0].id) as Promise<GitCredentialData>;
|
return getGitCredential(result[0].id) as Promise<GitCredentialData>;
|
||||||
}
|
}
|
||||||
@@ -1803,9 +1872,9 @@ export async function updateGitCredential(id: number, data: Partial<GitCredentia
|
|||||||
// Only update username if provided (empty string clears it)
|
// Only update username if provided (empty string clears it)
|
||||||
if (data.username !== undefined) updateData.username = data.username || null;
|
if (data.username !== undefined) updateData.username = data.username || null;
|
||||||
// Only update password/ssh keys if they have actual values (preserve existing if empty)
|
// Only update password/ssh keys if they have actual values (preserve existing if empty)
|
||||||
if (data.password) updateData.password = data.password;
|
if (data.password) updateData.password = encrypt(data.password);
|
||||||
if (data.sshPrivateKey) updateData.sshPrivateKey = data.sshPrivateKey;
|
if (data.sshPrivateKey) updateData.sshPrivateKey = encrypt(data.sshPrivateKey);
|
||||||
if (data.sshPassphrase) updateData.sshPassphrase = data.sshPassphrase;
|
if (data.sshPassphrase) updateData.sshPassphrase = encrypt(data.sshPassphrase);
|
||||||
|
|
||||||
await db.update(gitCredentials).set(updateData).where(eq(gitCredentials.id, id));
|
await db.update(gitCredentials).set(updateData).where(eq(gitCredentials.id, id));
|
||||||
return getGitCredential(id);
|
return getGitCredential(id);
|
||||||
@@ -4215,16 +4284,20 @@ export async function getStackEnvVars(
|
|||||||
.orderBy(asc(stackEnvironmentVariables.key));
|
.orderBy(asc(stackEnvironmentVariables.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.map(row => ({
|
return results.map(row => {
|
||||||
id: row.id,
|
// Decrypt secret values (decrypt handles both encrypted and plain text)
|
||||||
stackName: row.stackName,
|
const decryptedValue = row.isSecret ? (decrypt(row.value) ?? '') : row.value;
|
||||||
environmentId: row.environmentId,
|
return {
|
||||||
key: row.key,
|
id: row.id,
|
||||||
value: maskSecrets && row.isSecret ? '***' : row.value,
|
stackName: row.stackName,
|
||||||
isSecret: row.isSecret ?? false,
|
environmentId: row.environmentId,
|
||||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
key: row.key,
|
||||||
updatedAt: row.updatedAt ?? new Date().toISOString()
|
value: maskSecrets && row.isSecret ? '***' : decryptedValue,
|
||||||
}));
|
isSecret: row.isSecret ?? false,
|
||||||
|
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||||
|
updatedAt: row.updatedAt ?? new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4309,7 +4382,8 @@ export async function setStackEnvVars(
|
|||||||
stackName,
|
stackName,
|
||||||
environmentId,
|
environmentId,
|
||||||
key: v.key,
|
key: v.key,
|
||||||
value: v.value,
|
// Encrypt values that are marked as secrets
|
||||||
|
value: v.isSecret ? (encrypt(v.value) ?? '') : v.value,
|
||||||
isSecret: v.isSecret ?? false,
|
isSecret: v.isSecret ?? false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ export const sql = createConnection();
|
|||||||
|
|
||||||
// Initialize schema (runs async but we handle it)
|
// Initialize schema (runs async but we handle it)
|
||||||
initializeSchema(sql).catch((error) => {
|
initializeSchema(sql).catch((error) => {
|
||||||
console.error('Database initialization failed:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[DB] Database initialization failed:', errorMsg);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ function readMigrationJournal(migrationsFolder: string): MigrationJournal | null
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (config.verboseLogging) {
|
if (config.verboseLogging) {
|
||||||
console.error('Failed to read migration journal:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[DB] Failed to read migration journal:', errorMsg);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -986,7 +987,8 @@ export async function getDatabaseSchemaVersion(): Promise<SchemaInfo> {
|
|||||||
}
|
}
|
||||||
return { version: null, date: null };
|
return { version: null, date: null };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error getting schema version:', e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error('[DB] Error getting schema version:', errorMsg);
|
||||||
return { version: null, date: null };
|
return { version: null, date: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+406
-38
@@ -835,24 +835,44 @@ export interface DeviceMapping {
|
|||||||
permissions?: string;
|
permissions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GPU/device request for containers (e.g., Nvidia GPU) */
|
||||||
|
export interface DeviceRequest {
|
||||||
|
driver?: string;
|
||||||
|
count?: number;
|
||||||
|
deviceIDs?: string[];
|
||||||
|
capabilities?: string[][];
|
||||||
|
options?: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateContainerOptions {
|
export interface CreateContainerOptions {
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
ports?: { [key: string]: { HostPort: string } };
|
ports?: { [key: string]: { HostIp?: string; HostPort: string } };
|
||||||
volumes?: { [key: string]: {} };
|
volumes?: { [key: string]: {} };
|
||||||
volumeBinds?: string[];
|
volumeBinds?: string[];
|
||||||
env?: string[];
|
env?: string[];
|
||||||
labels?: { [key: string]: string };
|
labels?: { [key: string]: string };
|
||||||
cmd?: string[];
|
cmd?: string[];
|
||||||
|
entrypoint?: string[];
|
||||||
|
workingDir?: string;
|
||||||
restartPolicy?: string;
|
restartPolicy?: string;
|
||||||
restartMaxRetries?: number;
|
restartMaxRetries?: number;
|
||||||
networkMode?: string;
|
networkMode?: string;
|
||||||
networks?: string[];
|
networks?: string[];
|
||||||
|
/** Network aliases for the primary network */
|
||||||
|
networkAliases?: string[];
|
||||||
|
/** Static IPv4 address for the primary network */
|
||||||
|
networkIpv4Address?: string;
|
||||||
|
/** Static IPv6 address for the primary network */
|
||||||
|
networkIpv6Address?: string;
|
||||||
|
/** Gateway priority for the primary network (Docker Engine 28+) */
|
||||||
|
networkGwPriority?: number;
|
||||||
user?: string;
|
user?: string;
|
||||||
privileged?: boolean;
|
privileged?: boolean;
|
||||||
healthcheck?: HealthcheckConfig;
|
healthcheck?: HealthcheckConfig;
|
||||||
memory?: number;
|
memory?: number;
|
||||||
memoryReservation?: number;
|
memoryReservation?: number;
|
||||||
|
memorySwap?: number;
|
||||||
cpuShares?: number;
|
cpuShares?: number;
|
||||||
cpuQuota?: number;
|
cpuQuota?: number;
|
||||||
cpuPeriod?: number;
|
cpuPeriod?: number;
|
||||||
@@ -865,6 +885,56 @@ export interface CreateContainerOptions {
|
|||||||
dnsOptions?: string[];
|
dnsOptions?: string[];
|
||||||
securityOpt?: string[];
|
securityOpt?: string[];
|
||||||
ulimits?: UlimitConfig[];
|
ulimits?: UlimitConfig[];
|
||||||
|
// Terminal settings
|
||||||
|
tty?: boolean;
|
||||||
|
stdinOpen?: boolean;
|
||||||
|
// Process and memory settings
|
||||||
|
oomKillDisable?: boolean;
|
||||||
|
pidsLimit?: number;
|
||||||
|
shmSize?: number;
|
||||||
|
// Tmpfs mounts
|
||||||
|
tmpfs?: { [key: string]: string };
|
||||||
|
// Sysctls
|
||||||
|
sysctls?: { [key: string]: string };
|
||||||
|
// Logging configuration
|
||||||
|
logDriver?: string;
|
||||||
|
logOptions?: { [key: string]: string };
|
||||||
|
// Namespace settings
|
||||||
|
ipcMode?: string;
|
||||||
|
pidMode?: string;
|
||||||
|
utsMode?: string;
|
||||||
|
// Hostname
|
||||||
|
hostname?: string;
|
||||||
|
// Cgroup parent
|
||||||
|
cgroupParent?: string;
|
||||||
|
// Stop signal
|
||||||
|
stopSignal?: string;
|
||||||
|
// Init process
|
||||||
|
init?: boolean;
|
||||||
|
// Stop timeout
|
||||||
|
stopTimeout?: number;
|
||||||
|
// MAC address
|
||||||
|
macAddress?: string;
|
||||||
|
// Extra hosts (/etc/hosts entries)
|
||||||
|
extraHosts?: string[];
|
||||||
|
// Device requests (GPU access, etc.)
|
||||||
|
deviceRequests?: DeviceRequest[];
|
||||||
|
// Container runtime (e.g., 'runc', 'nvidia' for GPU containers)
|
||||||
|
runtime?: string;
|
||||||
|
// Read-only root filesystem
|
||||||
|
readonlyRootfs?: boolean;
|
||||||
|
// CPU pinning (e.g., "0-3", "0,1")
|
||||||
|
cpusetCpus?: string;
|
||||||
|
// NUMA memory nodes (e.g., "0-1", "0")
|
||||||
|
cpusetMems?: string;
|
||||||
|
// Additional groups for the container process
|
||||||
|
groupAdd?: string[];
|
||||||
|
// Memory swappiness (0-100)
|
||||||
|
memorySwappiness?: number;
|
||||||
|
// User namespace mode
|
||||||
|
usernsMode?: string;
|
||||||
|
// Domain name
|
||||||
|
domainname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContainer(options: CreateContainerOptions, envId?: number | null) {
|
export async function createContainer(options: CreateContainerOptions, envId?: number | null) {
|
||||||
@@ -929,14 +999,74 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
|||||||
|
|
||||||
if (options.networkMode) {
|
if (options.networkMode) {
|
||||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||||
|
|
||||||
|
// Build endpoint config for primary network with aliases, static IP, and gateway priority
|
||||||
|
const hasNetworkConfig = options.networkAliases?.length || options.networkIpv4Address || options.networkIpv6Address || options.networkGwPriority !== undefined;
|
||||||
|
if (hasNetworkConfig) {
|
||||||
|
const endpointConfig: any = {};
|
||||||
|
|
||||||
|
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||||
|
endpointConfig.Aliases = options.networkAliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||||
|
endpointConfig.IPAMConfig = {};
|
||||||
|
if (options.networkIpv4Address) {
|
||||||
|
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||||
|
}
|
||||||
|
if (options.networkIpv6Address) {
|
||||||
|
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway priority (Docker Engine 28+)
|
||||||
|
if (options.networkGwPriority !== undefined) {
|
||||||
|
endpointConfig.GwPriority = options.networkGwPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerConfig.NetworkingConfig = {
|
||||||
|
EndpointsConfig: {
|
||||||
|
[options.networkMode]: endpointConfig
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.networks && options.networks.length > 0) {
|
if (options.networks && options.networks.length > 0) {
|
||||||
containerConfig.HostConfig.NetworkMode = options.networks[0];
|
containerConfig.HostConfig.NetworkMode = options.networks[0];
|
||||||
|
|
||||||
|
// Build endpoint configs for all networks
|
||||||
|
const endpointsConfig: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const network of options.networks) {
|
||||||
|
const isFirstNetwork = network === options.networks[0];
|
||||||
|
const endpointConfig: any = {};
|
||||||
|
|
||||||
|
// Apply aliases, static IP, and gateway priority only to the first (primary) network
|
||||||
|
if (isFirstNetwork) {
|
||||||
|
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||||
|
endpointConfig.Aliases = options.networkAliases;
|
||||||
|
}
|
||||||
|
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||||
|
endpointConfig.IPAMConfig = {};
|
||||||
|
if (options.networkIpv4Address) {
|
||||||
|
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||||
|
}
|
||||||
|
if (options.networkIpv6Address) {
|
||||||
|
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gateway priority (Docker Engine 28+)
|
||||||
|
if (options.networkGwPriority !== undefined) {
|
||||||
|
endpointConfig.GwPriority = options.networkGwPriority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointsConfig[network] = endpointConfig;
|
||||||
|
}
|
||||||
|
|
||||||
containerConfig.NetworkingConfig = {
|
containerConfig.NetworkingConfig = {
|
||||||
EndpointsConfig: Object.fromEntries(
|
EndpointsConfig: endpointsConfig
|
||||||
options.networks.map(network => [network, {}])
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,6 +1130,163 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entrypoint
|
||||||
|
if (options.entrypoint && options.entrypoint.length > 0) {
|
||||||
|
containerConfig.Entrypoint = options.entrypoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working directory
|
||||||
|
if (options.workingDir) {
|
||||||
|
containerConfig.WorkingDir = options.workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hostname
|
||||||
|
if (options.hostname) {
|
||||||
|
containerConfig.Hostname = options.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTY and StdinOpen
|
||||||
|
if (options.tty !== undefined) {
|
||||||
|
containerConfig.Tty = options.tty;
|
||||||
|
}
|
||||||
|
if (options.stdinOpen !== undefined) {
|
||||||
|
containerConfig.OpenStdin = options.stdinOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory swap
|
||||||
|
if (options.memorySwap !== undefined) {
|
||||||
|
containerConfig.HostConfig.MemorySwap = options.memorySwap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OOM kill disable
|
||||||
|
if (options.oomKillDisable !== undefined) {
|
||||||
|
containerConfig.HostConfig.OomKillDisable = options.oomKillDisable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pids limit
|
||||||
|
if (options.pidsLimit !== undefined) {
|
||||||
|
containerConfig.HostConfig.PidsLimit = options.pidsLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared memory size
|
||||||
|
if (options.shmSize !== undefined) {
|
||||||
|
containerConfig.HostConfig.ShmSize = options.shmSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tmpfs mounts
|
||||||
|
if (options.tmpfs && Object.keys(options.tmpfs).length > 0) {
|
||||||
|
containerConfig.HostConfig.Tmpfs = options.tmpfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sysctls
|
||||||
|
if (options.sysctls && Object.keys(options.sysctls).length > 0) {
|
||||||
|
containerConfig.HostConfig.Sysctls = options.sysctls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging configuration
|
||||||
|
if (options.logDriver) {
|
||||||
|
containerConfig.HostConfig.LogConfig = {
|
||||||
|
Type: options.logDriver,
|
||||||
|
Config: options.logOptions || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC mode
|
||||||
|
if (options.ipcMode) {
|
||||||
|
containerConfig.HostConfig.IpcMode = options.ipcMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PID mode
|
||||||
|
if (options.pidMode) {
|
||||||
|
containerConfig.HostConfig.PidMode = options.pidMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTS mode
|
||||||
|
if (options.utsMode) {
|
||||||
|
containerConfig.HostConfig.UTSMode = options.utsMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cgroup parent
|
||||||
|
if (options.cgroupParent) {
|
||||||
|
containerConfig.HostConfig.CgroupParent = options.cgroupParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop signal
|
||||||
|
if (options.stopSignal) {
|
||||||
|
containerConfig.StopSignal = options.stopSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init process
|
||||||
|
if (options.init !== undefined) {
|
||||||
|
containerConfig.HostConfig.Init = options.init;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop timeout
|
||||||
|
if (options.stopTimeout !== undefined) {
|
||||||
|
containerConfig.StopTimeout = options.stopTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC address
|
||||||
|
if (options.macAddress) {
|
||||||
|
containerConfig.MacAddress = options.macAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra hosts (/etc/hosts entries)
|
||||||
|
if (options.extraHosts && options.extraHosts.length > 0) {
|
||||||
|
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device requests (GPU access, etc.)
|
||||||
|
if (options.deviceRequests && options.deviceRequests.length > 0) {
|
||||||
|
containerConfig.HostConfig.DeviceRequests = options.deviceRequests.map(dr => ({
|
||||||
|
Driver: dr.driver || '',
|
||||||
|
Count: dr.count ?? -1,
|
||||||
|
DeviceIDs: dr.deviceIDs || [],
|
||||||
|
Capabilities: dr.capabilities || [],
|
||||||
|
Options: dr.options || {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container runtime (e.g., 'nvidia' for GPU containers)
|
||||||
|
if (options.runtime) {
|
||||||
|
containerConfig.HostConfig.Runtime = options.runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only root filesystem
|
||||||
|
if (options.readonlyRootfs !== undefined) {
|
||||||
|
containerConfig.HostConfig.ReadonlyRootfs = options.readonlyRootfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU pinning
|
||||||
|
if (options.cpusetCpus) {
|
||||||
|
containerConfig.HostConfig.CpusetCpus = options.cpusetCpus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUMA memory nodes
|
||||||
|
if (options.cpusetMems) {
|
||||||
|
containerConfig.HostConfig.CpusetMems = options.cpusetMems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional groups
|
||||||
|
if (options.groupAdd && options.groupAdd.length > 0) {
|
||||||
|
containerConfig.HostConfig.GroupAdd = options.groupAdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory swappiness
|
||||||
|
if (options.memorySwappiness !== undefined) {
|
||||||
|
containerConfig.HostConfig.MemorySwappiness = options.memorySwappiness;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User namespace mode
|
||||||
|
if (options.usernsMode) {
|
||||||
|
containerConfig.HostConfig.UsernsMode = options.usernsMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain name
|
||||||
|
if (options.domainname) {
|
||||||
|
containerConfig.Domainname = options.domainname;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await dockerJsonRequest<{ Id: string }>(
|
const result = await dockerJsonRequest<{ Id: string }>(
|
||||||
`/containers/create?name=${encodeURIComponent(options.name)}`,
|
`/containers/create?name=${encodeURIComponent(options.name)}`,
|
||||||
{
|
{
|
||||||
@@ -1108,7 +1395,8 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
|||||||
console.log(`[Pull] No credentials found for ${registry}`);
|
console.log(`[Pull] No credentials found for ${registry}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Pull] Failed to lookup credentials:`, e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`[Pull] Failed to lookup credentials:`, errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use streaming: true for longer timeout on edge environments
|
// Use streaming: true for longer timeout on edge environments
|
||||||
@@ -1220,9 +1508,38 @@ function parseImageReference(imageName: string): { registry: string; repo: strin
|
|||||||
return { registry, repo, tag };
|
return { registry, repo, tag };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a registry URL into host and path components.
|
||||||
|
* Handles URLs with or without protocol, and preserves organization paths.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* 'https://registry.example.com/org' -> { host: 'registry.example.com', path: '/org', fullRegistry: 'registry.example.com/org' }
|
||||||
|
* 'ghcr.io' -> { host: 'ghcr.io', path: '', fullRegistry: 'ghcr.io' }
|
||||||
|
* 'registry.example.com:5000/myorg' -> { host: 'registry.example.com:5000', path: '/myorg', fullRegistry: 'registry.example.com:5000/myorg' }
|
||||||
|
*/
|
||||||
|
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string } {
|
||||||
|
// Remove protocol
|
||||||
|
const withoutProtocol = url.replace(/^https?:\/\//, '');
|
||||||
|
// Remove trailing slash
|
||||||
|
const trimmed = withoutProtocol.replace(/\/$/, '');
|
||||||
|
// Split on first slash (after port if present)
|
||||||
|
const slashIndex = trimmed.indexOf('/');
|
||||||
|
if (slashIndex === -1) {
|
||||||
|
return { host: trimmed, path: '', fullRegistry: trimmed };
|
||||||
|
}
|
||||||
|
const host = trimmed.substring(0, slashIndex);
|
||||||
|
const path = trimmed.substring(slashIndex); // includes leading /
|
||||||
|
return { host, path, fullRegistry: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find registry credentials from Dockhand's stored registries.
|
* Find registry credentials from Dockhand's stored registries.
|
||||||
* Matches by registry host (url field).
|
* Matches by registry URL including organization path if present.
|
||||||
|
*
|
||||||
|
* Matching logic:
|
||||||
|
* - Full match: stored 'registry.example.com/org' matches requested 'registry.example.com/org'
|
||||||
|
* - Host-only stored: stored 'registry.example.com' matches requested 'registry.example.com/org'
|
||||||
|
* (allows a single credential entry to work for all org paths)
|
||||||
*/
|
*/
|
||||||
async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
|
async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
|
||||||
try {
|
try {
|
||||||
@@ -1230,10 +1547,16 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
|||||||
const { getRegistries } = await import('./db.js');
|
const { getRegistries } = await import('./db.js');
|
||||||
const registries = await getRegistries();
|
const registries = await getRegistries();
|
||||||
|
|
||||||
|
const requested = parseRegistryUrl(registryHost);
|
||||||
|
|
||||||
for (const reg of registries) {
|
for (const reg of registries) {
|
||||||
// Match by URL - extract host from stored URL
|
const stored = parseRegistryUrl(reg.url);
|
||||||
const storedHost = reg.url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
||||||
if (storedHost === registryHost || reg.url.includes(registryHost)) {
|
// Match if:
|
||||||
|
// 1. Full registry paths match exactly, OR
|
||||||
|
// 2. Hosts match and stored registry has no path (applies to any org)
|
||||||
|
if (stored.fullRegistry === requested.fullRegistry ||
|
||||||
|
(stored.host === requested.host && !stored.path)) {
|
||||||
if (reg.username && reg.password) {
|
if (reg.username && reg.password) {
|
||||||
return { username: reg.username, password: reg.password };
|
return { username: reg.username, password: reg.password };
|
||||||
}
|
}
|
||||||
@@ -1241,13 +1564,13 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also check for Docker Hub variations
|
// Also check for Docker Hub variations
|
||||||
if (registryHost === 'index.docker.io' || registryHost === 'registry-1.docker.io') {
|
if (requested.host === 'index.docker.io' || requested.host === 'registry-1.docker.io') {
|
||||||
for (const reg of registries) {
|
for (const reg of registries) {
|
||||||
const storedHost = reg.url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
const stored = parseRegistryUrl(reg.url);
|
||||||
// Match all Docker Hub URL variations
|
// Match all Docker Hub URL variations
|
||||||
if (storedHost === 'docker.io' || storedHost === 'hub.docker.com' ||
|
if (stored.host === 'docker.io' || stored.host === 'hub.docker.com' ||
|
||||||
storedHost === 'registry.hub.docker.com' || storedHost === 'index.docker.io' ||
|
stored.host === 'registry.hub.docker.com' || stored.host === 'index.docker.io' ||
|
||||||
storedHost === 'registry-1.docker.io') {
|
stored.host === 'registry-1.docker.io') {
|
||||||
if (reg.username && reg.password) {
|
if (reg.username && reg.password) {
|
||||||
return { username: reg.username, password: reg.password };
|
return { username: reg.username, password: reg.password };
|
||||||
}
|
}
|
||||||
@@ -1257,7 +1580,8 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to lookup registry credentials:', e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error('[Registry] Failed to lookup credentials:', errorMsg);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1676,8 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
|
|||||||
return token ? `Bearer ${token}` : null;
|
return token ? `Bearer ${token}` : null;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to get registry bearer token:', e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error('[Registry] Failed to get bearer token:', errorMsg);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1364,7 +1689,7 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
|
|||||||
* 2. Parse realm, service from challenge
|
* 2. Parse realm, service from challenge
|
||||||
* 3. Request token from realm with credentials (if available)
|
* 3. Request token from realm with credentials (if available)
|
||||||
*
|
*
|
||||||
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io')
|
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io' or 'https://registry.example.com/org')
|
||||||
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
|
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
|
||||||
* @param credentials - Optional credentials { username, password }
|
* @param credentials - Optional credentials { username, password }
|
||||||
* @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null
|
* @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null
|
||||||
@@ -1375,15 +1700,12 @@ export async function getRegistryAuthHeader(
|
|||||||
credentials?: { username: string; password: string } | null
|
credentials?: { username: string; password: string } | null
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Normalize URL
|
// Parse URL to extract host (V2 API is always at the host root)
|
||||||
let baseUrl = registryUrl;
|
const parsed = parseRegistryUrl(registryUrl);
|
||||||
if (!baseUrl.startsWith('http')) {
|
const apiBaseUrl = `https://${parsed.host}`;
|
||||||
baseUrl = `https://${baseUrl}`;
|
|
||||||
}
|
|
||||||
baseUrl = baseUrl.replace(/\/$/, '');
|
|
||||||
|
|
||||||
// Step 1: Challenge request to /v2/
|
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
|
||||||
const challengeResponse = await fetch(`${baseUrl}/v2/`, {
|
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
headers: { 'User-Agent': 'Dockhand/1.0' }
|
||||||
});
|
});
|
||||||
@@ -1458,7 +1780,8 @@ export async function getRegistryAuthHeader(
|
|||||||
return token ? `Bearer ${token}` : null;
|
return token ? `Bearer ${token}` : null;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to get registry auth header:', e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error('[Registry] Failed to get auth header:', errorMsg);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1469,27 +1792,26 @@ export async function getRegistryAuthHeader(
|
|||||||
*
|
*
|
||||||
* @param registry - Registry object from database
|
* @param registry - Registry object from database
|
||||||
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
|
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
|
||||||
* @returns { baseUrl, authHeader } - Normalized URL and auth header (or null)
|
* @returns { baseUrl, orgPath, authHeader } - Base URL (host only for V2 API), org path, and auth header
|
||||||
*/
|
*/
|
||||||
export async function getRegistryAuth(
|
export async function getRegistryAuth(
|
||||||
registry: { url: string; username?: string | null; password?: string | null },
|
registry: { url: string; username?: string | null; password?: string | null },
|
||||||
scope: string
|
scope: string
|
||||||
): Promise<{ baseUrl: string; authHeader: string | null }> {
|
): Promise<{ baseUrl: string; orgPath: string; authHeader: string | null }> {
|
||||||
// Normalize URL
|
// Parse registry URL to extract host and organization path
|
||||||
let baseUrl = registry.url;
|
const parsed = parseRegistryUrl(registry.url);
|
||||||
if (!baseUrl.startsWith('http')) {
|
|
||||||
baseUrl = `https://${baseUrl}`;
|
// V2 API endpoints are always at the registry host root
|
||||||
}
|
const baseUrl = `https://${parsed.host}`;
|
||||||
baseUrl = baseUrl.replace(/\/$/, '');
|
|
||||||
|
|
||||||
// Get auth header using proper token flow
|
// Get auth header using proper token flow
|
||||||
const credentials = registry.username && registry.password
|
const credentials = registry.username && registry.password
|
||||||
? { username: registry.username, password: registry.password }
|
? { username: registry.username, password: registry.password }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const authHeader = await getRegistryAuthHeader(baseUrl, scope, credentials);
|
const authHeader = await getRegistryAuthHeader(registry.url, scope, credentials);
|
||||||
|
|
||||||
return { baseUrl, authHeader };
|
return { baseUrl, orgPath: parsed.path, authHeader };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2037,17 +2359,51 @@ export async function createNetwork(options: CreateNetworkOptions, envId?: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Network connect/disconnect operations
|
// Network connect/disconnect operations
|
||||||
|
export interface NetworkConnectOptions {
|
||||||
|
aliases?: string[];
|
||||||
|
ipv4Address?: string;
|
||||||
|
ipv6Address?: string;
|
||||||
|
gwPriority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function connectContainerToNetwork(
|
export async function connectContainerToNetwork(
|
||||||
networkId: string,
|
networkId: string,
|
||||||
containerId: string,
|
containerId: string,
|
||||||
envId?: number | null
|
envId?: number | null,
|
||||||
|
options?: NetworkConnectOptions
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const body: any = { Container: containerId };
|
||||||
|
|
||||||
|
// Add EndpointConfig for aliases, static IP, and gateway priority
|
||||||
|
if (options?.aliases || options?.ipv4Address || options?.ipv6Address || options?.gwPriority !== undefined) {
|
||||||
|
body.EndpointConfig = {};
|
||||||
|
|
||||||
|
if (options.aliases && options.aliases.length > 0) {
|
||||||
|
body.EndpointConfig.Aliases = options.aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.ipv4Address || options.ipv6Address) {
|
||||||
|
body.EndpointConfig.IPAMConfig = {};
|
||||||
|
if (options.ipv4Address) {
|
||||||
|
body.EndpointConfig.IPAMConfig.IPv4Address = options.ipv4Address;
|
||||||
|
}
|
||||||
|
if (options.ipv6Address) {
|
||||||
|
body.EndpointConfig.IPAMConfig.IPv6Address = options.ipv6Address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway priority (Docker Engine 28+)
|
||||||
|
if (options.gwPriority !== undefined) {
|
||||||
|
body.EndpointConfig.GwPriority = options.gwPriority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await dockerFetch(
|
const response = await dockerFetch(
|
||||||
`/networks/${networkId}/connect`,
|
`/networks/${networkId}/connect`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ Container: containerId })
|
body: JSON.stringify(body)
|
||||||
},
|
},
|
||||||
envId
|
envId
|
||||||
);
|
);
|
||||||
@@ -2420,6 +2776,18 @@ export async function runContainerWithStreaming(options: {
|
|||||||
await streamLocalStderr(containerId, options.envId, options.onStderr);
|
await streamLocalStderr(containerId, options.envId, options.onStderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for container to fully exit before fetching stdout
|
||||||
|
// The stderr stream may close before the container finishes writing to stdout
|
||||||
|
// Use a timeout to prevent hanging if something goes wrong (container should already be exited)
|
||||||
|
const waitPromise = dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Container wait timeout after 10s')), 10000)
|
||||||
|
);
|
||||||
|
await Promise.race([waitPromise, timeoutPromise]).catch((err) => {
|
||||||
|
// Log but don't fail - container might already be gone or stderr stream was reliable
|
||||||
|
console.warn(`[runContainerWithStreaming] Wait warning: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
// Container has exited. Now fetch stdout reliably (no race condition).
|
// Container has exited. Now fetch stdout reliably (no race condition).
|
||||||
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
||||||
return stdout;
|
return stdout;
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
/**
|
||||||
|
* Credential Encryption Module
|
||||||
|
*
|
||||||
|
* Provides AES-256-GCM encryption for sensitive credentials at rest.
|
||||||
|
* 1. No file, no env var: Generate key, save to file (initial setup)
|
||||||
|
* 2. File exists, no env var: Use file key (unchanged)
|
||||||
|
* 3. No file, env var set: Use env var key, do NOT save to file
|
||||||
|
* 4. File exists, env var set (same key): Use key, delete file (env var is source of truth)
|
||||||
|
* 5. File exists, env var set (different key): Re-encrypt with env var key, delete file
|
||||||
|
*
|
||||||
|
* Once a user provides ENCRYPTION_KEY, the key file is removed - the key lives only in memory
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Encryption algorithm: AES-256 with GCM mode (authenticated encryption) */
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
|
||||||
|
/** Initialization vector length in bytes */
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
|
||||||
|
/** Authentication tag length in bytes */
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
/** Encryption key length in bytes (256 bits) */
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
|
||||||
|
/** Prefix for encrypted values (version 1) */
|
||||||
|
const ENCRYPTED_PREFIX = 'enc:v1:';
|
||||||
|
|
||||||
|
/** File name for auto-generated encryption key */
|
||||||
|
const KEY_FILE_NAME = '.encryption_key';
|
||||||
|
|
||||||
|
let cachedKey: Buffer | null = null;
|
||||||
|
|
||||||
|
/** Pending key rotation state (set when env var differs from file) */
|
||||||
|
let pendingKeyRotation: { oldKey: Buffer; newKey: Buffer } | null = null;
|
||||||
|
|
||||||
|
function getDataDir(): string {
|
||||||
|
return process.env.DATA_DIR || './data';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the encryption key.
|
||||||
|
*
|
||||||
|
* Hybrid key management approach:
|
||||||
|
* 1. No file, no env var: Generate key, save to file (initial setup)
|
||||||
|
* 2. File exists, no env var: Use file key (unchanged)
|
||||||
|
* 3. No file, env var set: Use env var key, do NOT save to file
|
||||||
|
* 4. File exists, env var set (same key): Use key, delete file (env var is source of truth)
|
||||||
|
* 5. File exists, env var set (different key): Re-encrypt with env var key, delete file after migration
|
||||||
|
*
|
||||||
|
* Once user provides ENCRYPTION_KEY, the key file is removed - the key lives
|
||||||
|
* only in memory from the environment variable.
|
||||||
|
*/
|
||||||
|
function getOrCreateKey(): Buffer {
|
||||||
|
// Return cached key if available
|
||||||
|
if (cachedKey) {
|
||||||
|
return cachedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = getDataDir();
|
||||||
|
const keyPath = join(dataDir, KEY_FILE_NAME);
|
||||||
|
const envKey = process.env.ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
// 1. File exists?
|
||||||
|
if (existsSync(keyPath)) {
|
||||||
|
try {
|
||||||
|
const fileKey = readFileSync(keyPath);
|
||||||
|
if (fileKey.length !== KEY_LENGTH) {
|
||||||
|
throw new Error(`Key file has invalid length: expected ${KEY_LENGTH}, got ${fileKey.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env var also set? Env var takes over, file will be deleted
|
||||||
|
if (envKey) {
|
||||||
|
try {
|
||||||
|
const envKeyBuffer = Buffer.from(envKey, 'base64');
|
||||||
|
if (envKeyBuffer.length !== KEY_LENGTH) {
|
||||||
|
console.warn('[Encryption] WARNING: ENCRYPTION_KEY env var has invalid length (ignored)');
|
||||||
|
// Fall through to use file key
|
||||||
|
} else if (!fileKey.equals(envKeyBuffer)) {
|
||||||
|
// Different key - trigger key rotation mode
|
||||||
|
// File will be deleted after re-encryption in migrateCredentials()
|
||||||
|
console.log('[Encryption] Key change detected - will re-encrypt and remove key file');
|
||||||
|
pendingKeyRotation = { oldKey: fileKey, newKey: envKeyBuffer };
|
||||||
|
// Return OLD key for decryption first
|
||||||
|
cachedKey = fileKey;
|
||||||
|
return cachedKey;
|
||||||
|
} else {
|
||||||
|
// Same key - delete file immediately, env var is now source of truth
|
||||||
|
try {
|
||||||
|
unlinkSync(keyPath);
|
||||||
|
console.log('[Encryption] Using ENCRYPTION_KEY from environment, removed key file');
|
||||||
|
} catch (unlinkError) {
|
||||||
|
const msg = unlinkError instanceof Error ? unlinkError.message : String(unlinkError);
|
||||||
|
console.warn(`[Encryption] Could not remove key file: ${msg}`);
|
||||||
|
}
|
||||||
|
cachedKey = envKeyBuffer;
|
||||||
|
return cachedKey;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn('[Encryption] WARNING: ENCRYPTION_KEY env var is invalid (ignored)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No env var or invalid env var - use file key
|
||||||
|
cachedKey = fileKey;
|
||||||
|
console.log('[Encryption] Using encryption key from', keyPath);
|
||||||
|
return cachedKey;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Failed to read encryption key from ${keyPath}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. No file - env var set? Use it WITHOUT saving to file
|
||||||
|
if (envKey) {
|
||||||
|
try {
|
||||||
|
const keyBuffer = Buffer.from(envKey, 'base64');
|
||||||
|
if (keyBuffer.length !== KEY_LENGTH) {
|
||||||
|
throw new Error(`ENCRYPTION_KEY must be exactly ${KEY_LENGTH} bytes when decoded`);
|
||||||
|
}
|
||||||
|
cachedKey = keyBuffer;
|
||||||
|
console.log('[Encryption] Using ENCRYPTION_KEY from environment (not persisted to disk)');
|
||||||
|
return cachedKey;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Invalid ENCRYPTION_KEY: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No file, no env var - generate new key and save to file (initial setup)
|
||||||
|
// Ensure data directory exists before writing
|
||||||
|
if (!existsSync(dataDir)) {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Encryption] Generating new encryption key...');
|
||||||
|
cachedKey = randomBytes(KEY_LENGTH);
|
||||||
|
|
||||||
|
// Save key with restricted permissions (0600 = owner read/write only)
|
||||||
|
try {
|
||||||
|
writeFileSync(keyPath, cachedKey, { mode: 0o600 });
|
||||||
|
console.log('[Encryption] Saved new encryption key to', keyPath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Encryption] Warning: Failed to save encryption key to ${keyPath}: ${msg}`);
|
||||||
|
console.error('[Encryption] Encryption will work for this session but keys will be regenerated on restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ENCRYPTION / DECRYPTION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plain text value using AES-256-GCM.
|
||||||
|
*
|
||||||
|
* @param plaintext - The value to encrypt (or null/empty)
|
||||||
|
* @returns Encrypted value with "enc:v1:" prefix, or null/empty if input was null/empty
|
||||||
|
*
|
||||||
|
* Format: enc:v1:<base64(iv + authTag + ciphertext)>
|
||||||
|
*/
|
||||||
|
export function encrypt(plaintext: string | null | undefined): string | null {
|
||||||
|
// Pass through null/undefined/empty values
|
||||||
|
if (plaintext === null || plaintext === undefined || plaintext === '') {
|
||||||
|
return plaintext as string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't double-encrypt
|
||||||
|
if (plaintext.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getOrCreateKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Combine: iv (12 bytes) + authTag (16 bytes) + ciphertext
|
||||||
|
const combined = Buffer.concat([iv, authTag, ciphertext]);
|
||||||
|
|
||||||
|
return ENCRYPTED_PREFIX + combined.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value that may be encrypted or plain text.
|
||||||
|
*
|
||||||
|
* If the value doesn't have the "enc:v1:" prefix, it's assumed to be plain text and returned as-is.
|
||||||
|
*
|
||||||
|
* @param value - The value to decrypt (encrypted with prefix, plain text, null, or empty)
|
||||||
|
* @returns Decrypted value, or the original value if not encrypted, or null if input was null
|
||||||
|
*/
|
||||||
|
export function decrypt(value: string | null | undefined): string | null {
|
||||||
|
// Pass through null/undefined/empty values
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return value as string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BACKWARDS COMPATIBILITY: If no prefix, it's plain text - return as-is
|
||||||
|
if (!value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the base64 payload after the prefix
|
||||||
|
const payload = value.substring(ENCRYPTED_PREFIX.length);
|
||||||
|
|
||||||
|
let combined: Buffer;
|
||||||
|
try {
|
||||||
|
combined = Buffer.from(payload, 'base64');
|
||||||
|
} catch {
|
||||||
|
console.error('[Encryption] Failed to decode base64 payload');
|
||||||
|
// Return original value to avoid data loss
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate minimum length: iv (12) + authTag (16) + at least 1 byte ciphertext
|
||||||
|
if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) {
|
||||||
|
console.error('[Encryption] Encrypted payload is too short');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract components
|
||||||
|
const iv = combined.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = getOrCreateKey();
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(ciphertext),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Encryption] Decryption failed: ${msg}`);
|
||||||
|
// Return original value to avoid data loss (might be corrupted or wrong key)
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value is encrypted (has the encryption prefix).
|
||||||
|
*
|
||||||
|
* @param value - The value to check
|
||||||
|
* @returns true if the value appears to be encrypted
|
||||||
|
*/
|
||||||
|
export function isEncrypted(value: string | null | undefined): boolean {
|
||||||
|
return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new encryption key and return it as base64.
|
||||||
|
* Useful for generating ENCRYPTION_KEY environment variable values.
|
||||||
|
*
|
||||||
|
* @returns Base64-encoded 32-byte encryption key
|
||||||
|
*/
|
||||||
|
export function generateKey(): string {
|
||||||
|
return randomBytes(KEY_LENGTH).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached encryption key.
|
||||||
|
* Primarily for testing purposes.
|
||||||
|
*/
|
||||||
|
export function clearKeyCache(): void {
|
||||||
|
cachedKey = null;
|
||||||
|
pendingKeyRotation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize encryption and migrate unencrypted credentials.
|
||||||
|
*
|
||||||
|
* 1. Ensures encryption key exists (generates or loads from file/env var)
|
||||||
|
* 2. Checks for pending key rotation (re-encrypts with new key, removes key file)
|
||||||
|
* 3. Encrypts any values that don't have the "enc:v1:" prefix
|
||||||
|
*
|
||||||
|
* This is idempotent - safe to call on every startup.
|
||||||
|
*/
|
||||||
|
export async function migrateCredentials(): Promise<void> {
|
||||||
|
// IMPORTANT: Always initialize the key on startup, even if there are no credentials yet.
|
||||||
|
// This ensures the key file is created before any credentials are added.
|
||||||
|
getOrCreateKey();
|
||||||
|
|
||||||
|
console.log('[Encryption] Checking for unencrypted credentials...');
|
||||||
|
|
||||||
|
// Import database dynamically to avoid circular dependency
|
||||||
|
const {
|
||||||
|
db,
|
||||||
|
eq,
|
||||||
|
registries,
|
||||||
|
gitCredentials,
|
||||||
|
environments,
|
||||||
|
oidcConfig,
|
||||||
|
ldapConfig,
|
||||||
|
notificationSettings,
|
||||||
|
stackEnvironmentVariables
|
||||||
|
} = await import('./db/drizzle.js');
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
const keyPath = join(getDataDir(), KEY_FILE_NAME);
|
||||||
|
|
||||||
|
// Check for key rotation first
|
||||||
|
if (pendingKeyRotation) {
|
||||||
|
console.log('[Encryption] Performing key rotation - re-encrypting all credentials...');
|
||||||
|
|
||||||
|
// Decrypt everything with old key, then switch to new key
|
||||||
|
// The old key is already cached, so decrypt will use it
|
||||||
|
|
||||||
|
// 1. Collect all encrypted values (we need to decrypt then re-encrypt)
|
||||||
|
const allEncrypted: Array<{
|
||||||
|
table: string;
|
||||||
|
id: number;
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const regs = await db.select().from(registries);
|
||||||
|
for (const reg of regs) {
|
||||||
|
if (reg.password && isEncrypted(reg.password)) {
|
||||||
|
allEncrypted.push({ table: 'registries', id: reg.id, field: 'password', value: reg.password });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitCreds = await db.select().from(gitCredentials);
|
||||||
|
for (const cred of gitCreds) {
|
||||||
|
if (cred.password && isEncrypted(cred.password)) {
|
||||||
|
allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'password', value: cred.password });
|
||||||
|
}
|
||||||
|
if (cred.sshPrivateKey && isEncrypted(cred.sshPrivateKey)) {
|
||||||
|
allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'sshPrivateKey', value: cred.sshPrivateKey });
|
||||||
|
}
|
||||||
|
if (cred.sshPassphrase && isEncrypted(cred.sshPassphrase)) {
|
||||||
|
allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'sshPassphrase', value: cred.sshPassphrase });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envs = await db.select().from(environments);
|
||||||
|
for (const env of envs) {
|
||||||
|
if (env.hawserToken && isEncrypted(env.hawserToken)) {
|
||||||
|
allEncrypted.push({ table: 'environments', id: env.id, field: 'hawserToken', value: env.hawserToken });
|
||||||
|
}
|
||||||
|
if (env.tlsKey && isEncrypted(env.tlsKey)) {
|
||||||
|
allEncrypted.push({ table: 'environments', id: env.id, field: 'tlsKey', value: env.tlsKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcConfigs = await db.select().from(oidcConfig);
|
||||||
|
for (const config of oidcConfigs) {
|
||||||
|
if (config.clientSecret && isEncrypted(config.clientSecret)) {
|
||||||
|
allEncrypted.push({ table: 'oidcConfig', id: config.id, field: 'clientSecret', value: config.clientSecret });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ldapConfigs = await db.select().from(ldapConfig);
|
||||||
|
for (const config of ldapConfigs) {
|
||||||
|
if (config.bindPassword && isEncrypted(config.bindPassword)) {
|
||||||
|
allEncrypted.push({ table: 'ldapConfig', id: config.id, field: 'bindPassword', value: config.bindPassword });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifSettings = await db.select().from(notificationSettings);
|
||||||
|
for (const notif of notifSettings) {
|
||||||
|
if (notif.config) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(notif.config);
|
||||||
|
if (config.smtpPassword && isEncrypted(config.smtpPassword)) {
|
||||||
|
allEncrypted.push({ table: 'notificationSettings', id: notif.id, field: 'config.smtpPassword', value: config.smtpPassword });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackEnvVars = await db.select().from(stackEnvironmentVariables);
|
||||||
|
for (const envVar of stackEnvVars) {
|
||||||
|
if (envVar.isSecret && envVar.value && isEncrypted(envVar.value)) {
|
||||||
|
allEncrypted.push({ table: 'stackEnvironmentVariables', id: envVar.id, field: 'value', value: envVar.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt all values with old key
|
||||||
|
const decryptedValues: Map<string, string> = new Map();
|
||||||
|
for (const item of allEncrypted) {
|
||||||
|
const decrypted = decrypt(item.value);
|
||||||
|
if (decrypted) {
|
||||||
|
decryptedValues.set(`${item.table}:${item.id}:${item.field}`, decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to new key
|
||||||
|
cachedKey = pendingKeyRotation.newKey;
|
||||||
|
|
||||||
|
// Re-encrypt and update all values
|
||||||
|
for (const item of allEncrypted) {
|
||||||
|
const decrypted = decryptedValues.get(`${item.table}:${item.id}:${item.field}`);
|
||||||
|
if (decrypted) {
|
||||||
|
const reEncrypted = encrypt(decrypted);
|
||||||
|
|
||||||
|
// Update database based on table
|
||||||
|
if (item.table === 'registries') {
|
||||||
|
await db.update(registries).set({ [item.field]: reEncrypted }).where(eq(registries.id, item.id));
|
||||||
|
} else if (item.table === 'gitCredentials') {
|
||||||
|
await db.update(gitCredentials).set({ [item.field]: reEncrypted }).where(eq(gitCredentials.id, item.id));
|
||||||
|
} else if (item.table === 'environments') {
|
||||||
|
await db.update(environments).set({ [item.field]: reEncrypted }).where(eq(environments.id, item.id));
|
||||||
|
} else if (item.table === 'oidcConfig') {
|
||||||
|
await db.update(oidcConfig).set({ [item.field]: reEncrypted }).where(eq(oidcConfig.id, item.id));
|
||||||
|
} else if (item.table === 'ldapConfig') {
|
||||||
|
await db.update(ldapConfig).set({ [item.field]: reEncrypted }).where(eq(ldapConfig.id, item.id));
|
||||||
|
} else if (item.table === 'notificationSettings' && item.field === 'config.smtpPassword') {
|
||||||
|
// Need to update the JSON field
|
||||||
|
const notif = notifSettings.find(n => n.id === item.id);
|
||||||
|
if (notif) {
|
||||||
|
const config = JSON.parse(notif.config);
|
||||||
|
config.smtpPassword = reEncrypted;
|
||||||
|
await db.update(notificationSettings).set({ config: JSON.stringify(config) }).where(eq(notificationSettings.id, item.id));
|
||||||
|
}
|
||||||
|
} else if (item.table === 'stackEnvironmentVariables') {
|
||||||
|
await db.update(stackEnvironmentVariables).set({ value: reEncrypted }).where(eq(stackEnvironmentVariables.id, item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete key file - env var is now the source of truth
|
||||||
|
if (existsSync(keyPath)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(keyPath);
|
||||||
|
console.log('[Encryption] Deleted key file - now using ENCRYPTION_KEY from environment only');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[Encryption] Could not delete key file: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingKeyRotation = null;
|
||||||
|
|
||||||
|
if (migrated > 0) {
|
||||||
|
console.log(`[Encryption] Re-encrypted ${migrated} credentials with new key`);
|
||||||
|
} else {
|
||||||
|
console.log('[Encryption] Key rotation complete (no credentials to re-encrypt)');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regs = await db.select().from(registries);
|
||||||
|
for (const reg of regs) {
|
||||||
|
if (reg.password && !isEncrypted(reg.password)) {
|
||||||
|
await db.update(registries)
|
||||||
|
.set({ password: encrypt(reg.password) })
|
||||||
|
.where(eq(registries.id, reg.id));
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitCreds = await db.select().from(gitCredentials);
|
||||||
|
for (const cred of gitCreds) {
|
||||||
|
const updates: Record<string, string | null> = {};
|
||||||
|
if (cred.password && !isEncrypted(cred.password)) {
|
||||||
|
updates.password = encrypt(cred.password);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
if (cred.sshPrivateKey && !isEncrypted(cred.sshPrivateKey)) {
|
||||||
|
updates.sshPrivateKey = encrypt(cred.sshPrivateKey);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
if (cred.sshPassphrase && !isEncrypted(cred.sshPassphrase)) {
|
||||||
|
updates.sshPassphrase = encrypt(cred.sshPassphrase);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await db.update(gitCredentials).set(updates).where(eq(gitCredentials.id, cred.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envs = await db.select().from(environments);
|
||||||
|
for (const env of envs) {
|
||||||
|
const updates: Record<string, string | null> = {};
|
||||||
|
if (env.hawserToken && !isEncrypted(env.hawserToken)) {
|
||||||
|
updates.hawserToken = encrypt(env.hawserToken);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
if (env.tlsKey && !isEncrypted(env.tlsKey)) {
|
||||||
|
updates.tlsKey = encrypt(env.tlsKey);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await db.update(environments).set(updates).where(eq(environments.id, env.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcConfigs = await db.select().from(oidcConfig);
|
||||||
|
for (const config of oidcConfigs) {
|
||||||
|
if (config.clientSecret && !isEncrypted(config.clientSecret)) {
|
||||||
|
await db.update(oidcConfig)
|
||||||
|
.set({ clientSecret: encrypt(config.clientSecret) })
|
||||||
|
.where(eq(oidcConfig.id, config.id));
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ldapConfigs = await db.select().from(ldapConfig);
|
||||||
|
for (const config of ldapConfigs) {
|
||||||
|
if (config.bindPassword && !isEncrypted(config.bindPassword)) {
|
||||||
|
await db.update(ldapConfig)
|
||||||
|
.set({ bindPassword: encrypt(config.bindPassword) })
|
||||||
|
.where(eq(ldapConfig.id, config.id));
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifSettings = await db.select().from(notificationSettings);
|
||||||
|
for (const notif of notifSettings) {
|
||||||
|
if (notif.config) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(notif.config);
|
||||||
|
if (config.smtpPassword && !isEncrypted(config.smtpPassword)) {
|
||||||
|
config.smtpPassword = encrypt(config.smtpPassword);
|
||||||
|
await db.update(notificationSettings)
|
||||||
|
.set({ config: JSON.stringify(config) })
|
||||||
|
.where(eq(notificationSettings.id, notif.id));
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackEnvVars = await db.select().from(stackEnvironmentVariables);
|
||||||
|
for (const envVar of stackEnvVars) {
|
||||||
|
if (envVar.isSecret && envVar.value && !isEncrypted(envVar.value)) {
|
||||||
|
await db.update(stackEnvironmentVariables)
|
||||||
|
.set({ value: encrypt(envVar.value) })
|
||||||
|
.where(eq(stackEnvironmentVariables.id, envVar.id));
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated > 0) {
|
||||||
|
console.log(`[Encryption] Migrated ${migrated} credentials to encrypted storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-6
@@ -137,6 +137,45 @@ async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of files that changed between two commits in a specific directory.
|
||||||
|
* Returns array of changed file paths (relative to repo root).
|
||||||
|
*/
|
||||||
|
async function getChangedFilesInDir(
|
||||||
|
repoPath: string,
|
||||||
|
previousCommit: string,
|
||||||
|
newCommit: string,
|
||||||
|
dirPath: string,
|
||||||
|
env: GitEnv
|
||||||
|
): Promise<{ changed: boolean; files: string[]; error?: string }> {
|
||||||
|
if (!previousCommit) {
|
||||||
|
// No previous commit means this is a new clone - always deploy
|
||||||
|
return { changed: true, files: ['(new clone - all files)'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use git diff --name-only to get all changed files in the directory
|
||||||
|
// The trailing slash ensures we only match files IN that directory (and subdirs)
|
||||||
|
const dirPattern = dirPath.endsWith('/') ? dirPath : `${dirPath}/`;
|
||||||
|
const result = await execGit(
|
||||||
|
['diff', '--name-only', previousCommit, newCommit, '--', dirPattern],
|
||||||
|
repoPath,
|
||||||
|
env
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the command fails (e.g., previousCommit no longer exists after force push),
|
||||||
|
// assume files changed to be safe
|
||||||
|
if (result.code !== 0) {
|
||||||
|
return { changed: true, files: ['(diff failed - assuming changed)'], error: result.stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse changed files
|
||||||
|
const changedFiles = result.stdout.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(f => f.length > 0);
|
||||||
|
|
||||||
|
return { changed: changedFiles.length > 0, files: changedFiles };
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
commit?: string;
|
commit?: string;
|
||||||
@@ -148,6 +187,7 @@ export interface SyncResult {
|
|||||||
envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env")
|
envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env")
|
||||||
error?: string;
|
error?: string;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
|
changedFiles?: string[]; // List of files that changed (for logging/debugging)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
@@ -497,7 +537,8 @@ export function deleteRepositoryFiles(repoId: number): void {
|
|||||||
rmSync(repoPath, { recursive: true, force: true });
|
rmSync(repoPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete repository files:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Git] Failed to delete repository files:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,8 +641,45 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
|||||||
// Check if commit changed
|
// Check if commit changed
|
||||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||||
const newCommit = newCommitResult.stdout.trim();
|
const newCommit = newCommitResult.stdout.trim();
|
||||||
updated = previousCommit !== newCommit;
|
const commitChanged = previousCommit !== newCommit;
|
||||||
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, updated: ${updated}`);
|
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);
|
||||||
|
|
||||||
|
// Check if any files in the compose file's directory have changed
|
||||||
|
// This catches changes to the compose file, env files, and any other referenced files
|
||||||
|
// (e.g., config files, scripts, additional env files)
|
||||||
|
let changedFiles: string[] = [];
|
||||||
|
if (commitChanged) {
|
||||||
|
// Get the directory containing the compose file (relative to repo root)
|
||||||
|
const composeDirRelative = dirname(gitStack.composePath);
|
||||||
|
console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`);
|
||||||
|
|
||||||
|
const diffResult = await getChangedFilesInDir(
|
||||||
|
repoPath,
|
||||||
|
previousCommit,
|
||||||
|
newCommit,
|
||||||
|
composeDirRelative || '.',
|
||||||
|
env
|
||||||
|
);
|
||||||
|
|
||||||
|
updated = diffResult.changed;
|
||||||
|
changedFiles = diffResult.files;
|
||||||
|
|
||||||
|
if (diffResult.error) {
|
||||||
|
console.log(`${logPrefix} Diff error: ${diffResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedFiles.length > 0) {
|
||||||
|
console.log(`${logPrefix} Changed files (${changedFiles.length}):`);
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
console.log(`${logPrefix} - ${file}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`${logPrefix} No files changed in stack directory`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updated = false;
|
||||||
|
console.log(`${logPrefix} No commit change, skipping file diff`);
|
||||||
|
}
|
||||||
|
|
||||||
// Get current commit hash
|
// Get current commit hash
|
||||||
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||||
@@ -671,6 +749,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
|||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
console.log(`${logPrefix} Success: true`);
|
console.log(`${logPrefix} Success: true`);
|
||||||
console.log(`${logPrefix} Updated:`, updated);
|
console.log(`${logPrefix} Updated:`, updated);
|
||||||
|
console.log(`${logPrefix} Changed files:`, changedFiles.length > 0 ? changedFiles.join(', ') : '(none)');
|
||||||
console.log(`${logPrefix} Commit:`, currentCommit);
|
console.log(`${logPrefix} Commit:`, currentCommit);
|
||||||
console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0);
|
console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0);
|
||||||
|
|
||||||
@@ -682,7 +761,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
|||||||
composeFileName,
|
composeFileName,
|
||||||
envFileVars,
|
envFileVars,
|
||||||
envFileName,
|
envFileName,
|
||||||
updated
|
updated,
|
||||||
|
changedFiles
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
cleanupSshKey(credential);
|
cleanupSshKey(credential);
|
||||||
@@ -850,7 +930,8 @@ export function deleteGitStackFiles(stackId: number): void {
|
|||||||
rmSync(repoPath, { recursive: true, force: true });
|
rmSync(repoPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete git stack files:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Git] Failed to delete git stack files:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,7 +1011,23 @@ export async function deployGitStackWithProgress(
|
|||||||
// Check if commit changed
|
// Check if commit changed
|
||||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||||
const newCommit = newCommitResult.stdout.trim();
|
const newCommit = newCommitResult.stdout.trim();
|
||||||
updated = previousCommit !== newCommit;
|
const commitChanged = previousCommit !== newCommit;
|
||||||
|
|
||||||
|
// Check if any files in the compose file's directory have changed
|
||||||
|
// (for consistency with syncGitStack, though this function always deploys)
|
||||||
|
if (commitChanged) {
|
||||||
|
const composeDir = dirname(gitStack.composePath);
|
||||||
|
const diffResult = await getChangedFilesInDir(
|
||||||
|
repoPath,
|
||||||
|
previousCommit,
|
||||||
|
newCommit,
|
||||||
|
composeDir || '.',
|
||||||
|
env
|
||||||
|
);
|
||||||
|
updated = diffResult.changed;
|
||||||
|
} else {
|
||||||
|
updated = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Get current commit hash
|
// Get current commit hash
|
||||||
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ export async function handleEdgeContainerEvent(
|
|||||||
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
||||||
}, event.image);
|
}, event.image);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Hawser] Error handling container event:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Hawser] Error handling container event:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +227,8 @@ export async function handleEdgeMetrics(
|
|||||||
environmentId
|
environmentId
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Hawser] Error saving metrics:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Hawser] Error saving metrics:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +367,8 @@ export function closeEdgeConnection(environmentId: number): void {
|
|||||||
try {
|
try {
|
||||||
connection.ws.close(1000, 'Environment deleted');
|
connection.ws.close(1000, 'Environment deleted');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Hawser] Error closing WebSocket for environment ${environmentId}:`, e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`[Hawser] Error closing WebSocket for environment ${environmentId}:`, errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeConnections.delete(environmentId);
|
edgeConnections.delete(environmentId);
|
||||||
@@ -578,7 +581,8 @@ export async function sendEdgeRequest(
|
|||||||
try {
|
try {
|
||||||
connection.ws.send(messageStr);
|
connection.ws.send(messageStr);
|
||||||
} catch (sendError) {
|
} catch (sendError) {
|
||||||
console.error(`[Hawser Edge] Error sending message:`, sendError);
|
const errorMsg = sendError instanceof Error ? sendError.message : String(sendError);
|
||||||
|
console.error(`[Hawser Edge] Error sending message:`, errorMsg);
|
||||||
connection.pendingRequests.delete(requestId);
|
connection.pendingRequests.delete(requestId);
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
connection.pendingStreamRequests.delete(requestId);
|
connection.pendingStreamRequests.delete(requestId);
|
||||||
@@ -650,9 +654,10 @@ export function sendEdgeStreamRequest(
|
|||||||
try {
|
try {
|
||||||
connection.ws.send(messageStr);
|
connection.ws.send(messageStr);
|
||||||
} catch (sendError) {
|
} catch (sendError) {
|
||||||
console.error(`[Hawser Edge] Error sending streaming message:`, sendError);
|
const errorMsg = sendError instanceof Error ? sendError.message : String(sendError);
|
||||||
|
console.error(`[Hawser Edge] Error sending streaming message:`, errorMsg);
|
||||||
connection.pendingStreamRequests.delete(requestId);
|
connection.pendingStreamRequests.delete(requestId);
|
||||||
callbacks.onError(sendError instanceof Error ? sendError.message : String(sendError));
|
callbacks.onError(errorMsg);
|
||||||
return { requestId: '', cancel: () => {} };
|
return { requestId: '', cancel: () => {} };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export async function checkLicenseExpiry(): Promise<void> {
|
|||||||
lastLicenseExpiryNotification = Date.now();
|
lastLicenseExpiryNotification = Date.now();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[License] Failed to check license expiry:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[License] Failed to check license expiry:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,18 @@ import {
|
|||||||
type NotificationEventType
|
type NotificationEventType
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
|
// Escape special characters for Telegram Markdown
|
||||||
|
function escapeTelegramMarkdown(text: string): string {
|
||||||
|
// Escape characters that have special meaning in Telegram Markdown
|
||||||
|
return text
|
||||||
|
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||||
|
.replace(/_/g, '\\_') // Underscore (italic)
|
||||||
|
.replace(/\*/g, '\\*') // Asterisk (bold)
|
||||||
|
.replace(/\[/g, '\\[') // Opening bracket (link)
|
||||||
|
.replace(/\]/g, '\\]') // Closing bracket (link)
|
||||||
|
.replace(/`/g, '\\`'); // Backtick (code)
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationPayload {
|
export interface NotificationPayload {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -57,7 +69,8 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Notifications] SMTP send failed:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Notifications] SMTP send failed:', errorMsg);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +84,8 @@ async function sendAppriseNotification(config: AppriseConfig, payload: Notificat
|
|||||||
const sent = await sendToAppriseUrl(url, payload);
|
const sent = await sendToAppriseUrl(url, payload);
|
||||||
if (!sent) success = false;
|
if (!sent) success = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Notifications] Failed to send to ${url}:`, error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Notifications] Failed to send to ${url}:`, errorMsg);
|
||||||
success = false;
|
success = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +131,8 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Notifications] Failed to parse Apprise URL:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Notifications] Failed to parse Apprise URL:', errorMsg);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,14 +196,18 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
|||||||
const [, botToken, chatId] = match;
|
const [, botToken, chatId] = match;
|
||||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||||
|
|
||||||
const envTag = payload.environmentName ? ` \\[${payload.environmentName}\\]` : '';
|
// Escape markdown special characters in title and message
|
||||||
|
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||||
|
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||||
|
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: `*${payload.title}*${envTag}\n${payload.message}`,
|
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
|
||||||
parse_mode: 'Markdown'
|
parse_mode: 'Markdown'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -200,7 +219,8 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
|||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Notifications] Telegram send failed:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Notifications] Telegram send failed:', errorMsg);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,7 +441,8 @@ export async function sendEnvironmentNotification(
|
|||||||
if (success) sent++;
|
if (success) sent++;
|
||||||
else allSuccess = false;
|
else allSuccess = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
|
||||||
allSuccess = false;
|
allSuccess = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,7 +514,8 @@ export async function sendEventNotification(
|
|||||||
if (success) sent++;
|
if (success) sent++;
|
||||||
else allSuccess = false;
|
else allSuccess = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
|
||||||
allSuccess = false;
|
allSuccess = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-12
@@ -232,7 +232,8 @@ async function ensureScannerImage(
|
|||||||
await pullImage(scannerImage, undefined, envId);
|
await pullImage(scannerImage, undefined, envId);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to pull scanner image ${scannerImage}:`, error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Scanner] Failed to pull image ${scannerImage}:`, errorMsg);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,8 +282,11 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Grype] Failed to parse output:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[Grype] Output was:', output.slice(0, 500));
|
console.error('[Grype] Failed to parse output:', errorMsg);
|
||||||
|
if (output.length > 0) {
|
||||||
|
console.error('[Grype] Output preview:', output.slice(0, 200));
|
||||||
|
}
|
||||||
// Check if output looks like an error message from grype
|
// Check if output looks like an error message from grype
|
||||||
const firstLine = output.split('\n')[0].trim();
|
const firstLine = output.split('\n')[0].trim();
|
||||||
if (firstLine && !firstLine.startsWith('{')) {
|
if (firstLine && !firstLine.startsWith('{')) {
|
||||||
@@ -337,8 +341,11 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Trivy] Failed to parse output:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[Trivy] Output was:', output.slice(0, 500));
|
console.error('[Trivy] Failed to parse output:', errorMsg);
|
||||||
|
if (output.length > 0) {
|
||||||
|
console.error('[Trivy] Output preview:', output.slice(0, 200));
|
||||||
|
}
|
||||||
// Check if output looks like an error message from trivy
|
// Check if output looks like an error message from trivy
|
||||||
const firstLine = output.split('\n')[0].trim();
|
const firstLine = output.split('\n')[0].trim();
|
||||||
if (firstLine && !firstLine.startsWith('{')) {
|
if (firstLine && !firstLine.startsWith('{')) {
|
||||||
@@ -667,7 +674,8 @@ export async function scanImage(
|
|||||||
const result = await scanWithGrype(imageName, envId, onProgress);
|
const result = await scanWithGrype(imageName, envId, onProgress);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Grype scan failed:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Grype] Scan failed:', errorMsg);
|
||||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||||
if (scannerType === 'grype') throw error;
|
if (scannerType === 'grype') throw error;
|
||||||
}
|
}
|
||||||
@@ -678,7 +686,8 @@ export async function scanImage(
|
|||||||
const result = await scanWithTrivy(imageName, envId, onProgress);
|
const result = await scanWithTrivy(imageName, envId, onProgress);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Trivy scan failed:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Trivy] Scan failed:', errorMsg);
|
||||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||||
if (scannerType === 'trivy') throw error;
|
if (scannerType === 'trivy') throw error;
|
||||||
}
|
}
|
||||||
@@ -703,7 +712,8 @@ export async function scanImage(
|
|||||||
|
|
||||||
// Send notifications (async, don't block return)
|
// Send notifications (async, don't block return)
|
||||||
sendVulnerabilityNotifications(imageName, combinedSummary, envId).catch(err => {
|
sendVulnerabilityNotifications(imageName, combinedSummary, envId).catch(err => {
|
||||||
console.error('[Scanner] Failed to send vulnerability notifications:', err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[Scanner] Failed to send vulnerability notifications:', errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +776,8 @@ async function getScannerVersion(
|
|||||||
|
|
||||||
return version;
|
return version;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to get ${scannerType} version:`, error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Scanner] Failed to get ${scannerType} version:`, errorMsg);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,11 +826,13 @@ export async function checkScannerUpdates(envId?: number): Promise<{
|
|||||||
result[scanner].hasUpdate = false;
|
result[scanner].hasUpdate = false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to check updates for ${scanner}:`, error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Scanner] Failed to check updates for ${scanner}:`, errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check scanner updates:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scanner] Failed to check scanner updates:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -838,6 +851,7 @@ export async function cleanupScannerVolumes(envId?: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cleanup scanner volumes:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scanner] Failed to cleanup scanner volumes:', errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getEnvironmentTimezone,
|
getEnvironmentTimezone,
|
||||||
getDefaultTimezone
|
getDefaultTimezone
|
||||||
} from '../db';
|
} from '../db';
|
||||||
|
import { db, gitStacks, eq } from '../db/drizzle.js';
|
||||||
import {
|
import {
|
||||||
cleanupStaleVolumeHelpers,
|
cleanupStaleVolumeHelpers,
|
||||||
cleanupExpiredVolumeHelpers
|
cleanupExpiredVolumeHelpers
|
||||||
@@ -57,6 +58,30 @@ let volumeHelperCleanupJob: Cron | null = null;
|
|||||||
// Scheduler state
|
// Scheduler state
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stale 'syncing' states from git stacks.
|
||||||
|
* Called on startup to recover from crashes during sync operations.
|
||||||
|
*/
|
||||||
|
async function cleanupStaleSyncStates(): Promise<void> {
|
||||||
|
const staleStacks = await db.select().from(gitStacks).where(eq(gitStacks.syncStatus, 'syncing'));
|
||||||
|
|
||||||
|
if (staleStacks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Recovering ${staleStacks.length} git stack(s) from stale syncing state`);
|
||||||
|
|
||||||
|
for (const stack of staleStacks) {
|
||||||
|
await db.update(gitStacks).set({
|
||||||
|
syncStatus: 'pending',
|
||||||
|
syncError: 'Recovered from interrupted sync on startup',
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}).where(eq(gitStacks.id, stack.id));
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Reset git stack "${stack.stackName}" (ID: ${stack.id}) to pending`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the unified scheduler service.
|
* Start the unified scheduler service.
|
||||||
* Registers all schedules with croner for automatic execution.
|
* Registers all schedules with croner for automatic execution.
|
||||||
@@ -70,6 +95,9 @@ export async function startScheduler(): Promise<void> {
|
|||||||
console.log('[Scheduler] Starting scheduler service...');
|
console.log('[Scheduler] Starting scheduler service...');
|
||||||
isRunning = true;
|
isRunning = true;
|
||||||
|
|
||||||
|
// Clean up stale sync states from previous crashed processes
|
||||||
|
await cleanupStaleSyncStates();
|
||||||
|
|
||||||
// Get cron expressions and default timezone from database
|
// Get cron expressions and default timezone from database
|
||||||
const scheduleCleanupCron = await getScheduleCleanupCron();
|
const scheduleCleanupCron = await getScheduleCleanupCron();
|
||||||
const eventCleanupCron = await getEventCleanupCron();
|
const eventCleanupCron = await getEventCleanupCron();
|
||||||
@@ -102,7 +130,8 @@ export async function startScheduler(): Promise<void> {
|
|||||||
|
|
||||||
// Run volume helper cleanup immediately on startup to clean up stale containers
|
// Run volume helper cleanup immediately on startup to clean up stale containers
|
||||||
runVolumeHelperCleanupJob('startup', volumeCleanupFns).catch(err => {
|
runVolumeHelperCleanupJob('startup', volumeCleanupFns).catch(err => {
|
||||||
console.error('[Scheduler] Error during startup volume helper cleanup:', err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[Scheduler] Error during startup volume helper cleanup:', errorMsg);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||||
@@ -177,7 +206,8 @@ export async function refreshAllSchedules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scheduler] Error loading container schedules:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scheduler] Error loading container schedules:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register git stack auto-sync schedules
|
// Register git stack auto-sync schedules
|
||||||
@@ -194,7 +224,8 @@ export async function refreshAllSchedules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scheduler] Error loading git stack schedules:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scheduler] Error loading git stack schedules:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register environment update check schedules
|
// Register environment update check schedules
|
||||||
@@ -212,7 +243,8 @@ export async function refreshAllSchedules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scheduler] Error loading env update check schedules:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scheduler] Error loading env update check schedules:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules`);
|
console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules`);
|
||||||
@@ -337,7 +369,8 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scheduler] Error refreshing container schedules:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scheduler] Error refreshing container schedules:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-register git stack auto-sync schedules for this environment
|
// Re-register git stack auto-sync schedules for this environment
|
||||||
@@ -354,7 +387,8 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scheduler] Error refreshing git stack schedules:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scheduler] Error refreshing git stack schedules:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-register environment update check schedule for this environment
|
// Re-register environment update check schedule for this environment
|
||||||
@@ -369,7 +403,8 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro
|
|||||||
if (registered) refreshedCount++;
|
if (registered) refreshedCount++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scheduler] Error refreshing env update check schedule:', error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[Scheduler] Error refreshing env update check schedule:', errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`);
|
console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`);
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
* Container Auto-Update Task
|
* Container Auto-Update Task
|
||||||
*
|
*
|
||||||
* Handles automatic container updates with vulnerability scanning.
|
* Handles automatic container updates with vulnerability scanning.
|
||||||
|
*
|
||||||
|
* For containers that are part of a Docker Compose stack, updates use
|
||||||
|
* `docker compose up -d` to preserve ALL configuration from the compose file
|
||||||
|
* (network aliases, static IPs, health checks, resource limits, etc.).
|
||||||
|
*
|
||||||
|
* For standalone containers, updates use container recreation with comprehensive
|
||||||
|
* settings preservation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db';
|
import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db';
|
||||||
@@ -21,17 +28,20 @@ import {
|
|||||||
inspectContainer,
|
inspectContainer,
|
||||||
createContainer,
|
createContainer,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
|
startContainer,
|
||||||
removeContainer,
|
removeContainer,
|
||||||
checkImageUpdateAvailable,
|
checkImageUpdateAvailable,
|
||||||
getTempImageTag,
|
getTempImageTag,
|
||||||
isDigestBasedImage,
|
isDigestBasedImage,
|
||||||
getImageIdByTag,
|
getImageIdByTag,
|
||||||
removeTempImage,
|
removeTempImage,
|
||||||
tagImage
|
tagImage,
|
||||||
|
connectContainerToNetwork
|
||||||
} from '../../docker';
|
} from '../../docker';
|
||||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||||
import { sendEventNotification } from '../../notifications';
|
import { sendEventNotification } from '../../notifications';
|
||||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||||
|
import { startStack, getStackComposeFile } from '../../stacks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a container auto-update.
|
* Execute a container auto-update.
|
||||||
@@ -120,6 +130,18 @@ export async function runContainerUpdate(
|
|||||||
log(`Container is using image: ${imageNameFromConfig}`);
|
log(`Container is using image: ${imageNameFromConfig}`);
|
||||||
log(`Current image ID: ${currentImageId?.substring(0, 19)}`);
|
log(`Current image ID: ${currentImageId?.substring(0, 19)}`);
|
||||||
|
|
||||||
|
// Detect if container is part of a Docker Compose stack
|
||||||
|
const containerLabels = inspectData.Config?.Labels || {};
|
||||||
|
const composeProject = containerLabels['com.docker.compose.project'];
|
||||||
|
const composeService = containerLabels['com.docker.compose.service'];
|
||||||
|
const isStackContainer = !!composeProject;
|
||||||
|
|
||||||
|
if (isStackContainer) {
|
||||||
|
log(`Container is part of compose stack: ${composeProject} (service: ${composeService})`);
|
||||||
|
} else {
|
||||||
|
log(`Container is standalone (not part of a compose stack)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Get scanner and schedule settings early to determine scan strategy
|
// Get scanner and schedule settings early to determine scan strategy
|
||||||
const [scannerSettings, updateSetting] = await Promise.all([
|
const [scannerSettings, updateSetting] = await Promise.all([
|
||||||
getScannerSettings(envId),
|
getScannerSettings(envId),
|
||||||
@@ -431,8 +453,30 @@ export async function runContainerUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Proceeding with container recreation...`);
|
// =============================================================================
|
||||||
const success = await recreateContainer(containerName, envId, log);
|
// Update the container based on type
|
||||||
|
// =============================================================================
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
if (isStackContainer) {
|
||||||
|
log(`Updating via docker compose for stack: ${composeProject}`);
|
||||||
|
|
||||||
|
// Try stack-based update first
|
||||||
|
const stackSuccess = await updateStackContainer(composeProject!, composeService!, envId, log);
|
||||||
|
|
||||||
|
if (stackSuccess) {
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
// Fallback: Stack is external (not managed by Dockhand), use container recreation
|
||||||
|
log(`Fallback: Recreating container directly (stack "${composeProject}" not managed by Dockhand)`);
|
||||||
|
log(`WARNING: Some compose-specific settings may not be preserved`);
|
||||||
|
log(`Consider importing this stack into Dockhand for full configuration preservation`);
|
||||||
|
success = await recreateContainer(containerName, envId, log);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`Updating standalone container via recreation...`);
|
||||||
|
success = await recreateContainer(containerName, envId, log);
|
||||||
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await updateAutoUpdateLastUpdated(containerName, envId);
|
await updateAutoUpdateLastUpdated(containerName, envId);
|
||||||
@@ -504,10 +548,18 @@ export async function runContainerUpdate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HELPER FUNCTIONS
|
// EXPORTED HELPER FUNCTIONS (reused by batch-update-stream and batch-update)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
async function recreateContainer(
|
/**
|
||||||
|
* Recreate a standalone container with comprehensive settings preservation.
|
||||||
|
* Extracts and preserves 50+ container settings from the original container.
|
||||||
|
*
|
||||||
|
* Note: For containers that are part of a Docker Compose stack, use
|
||||||
|
* updateStackContainer() instead, which uses `docker compose up -d` to
|
||||||
|
* preserve ALL settings including network aliases, static IPs, etc.
|
||||||
|
*/
|
||||||
|
export async function recreateContainer(
|
||||||
containerName: string,
|
containerName: string,
|
||||||
envId?: number,
|
envId?: number,
|
||||||
log?: (msg: string) => void
|
log?: (msg: string) => void
|
||||||
@@ -529,6 +581,7 @@ async function recreateContainer(
|
|||||||
const hostConfig = inspectData.HostConfig;
|
const hostConfig = inspectData.HostConfig;
|
||||||
|
|
||||||
log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`);
|
log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`);
|
||||||
|
log?.(`Preserving all container settings...`);
|
||||||
|
|
||||||
// Stop container if running
|
// Stop container if running
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
@@ -540,40 +593,438 @@ async function recreateContainer(
|
|||||||
log?.('Removing old container...');
|
log?.('Removing old container...');
|
||||||
await removeContainer(container.id, true, envId);
|
await removeContainer(container.id, true, envId);
|
||||||
|
|
||||||
// Prepare port bindings
|
// =============================================================================
|
||||||
const ports: { [key: string]: { HostPort: string } } = {};
|
// Extract ALL settings from the original container
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Port bindings - preserve all host port mappings including HostIp
|
||||||
|
const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {};
|
||||||
if (hostConfig.PortBindings) {
|
if (hostConfig.PortBindings) {
|
||||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
||||||
if (bindings && (bindings as any[]).length > 0) {
|
if (bindings && (bindings as any[]).length > 0) {
|
||||||
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
|
const binding = (bindings as any[])[0];
|
||||||
|
ports[containerPort] = {
|
||||||
|
HostPort: binding.HostPort || ''
|
||||||
|
};
|
||||||
|
// Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose)
|
||||||
|
if (binding.HostIp) {
|
||||||
|
ports[containerPort].HostIp = binding.HostIp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new container
|
// Volume bindings - preserve ALL volumes including anonymous volumes
|
||||||
log?.('Creating new container...');
|
// hostConfig.Binds contains named volumes and bind mounts in "source:dest" format
|
||||||
|
// inspectData.Mounts contains ALL mounts including anonymous volumes with their generated names
|
||||||
|
const volumeBinds: string[] = [];
|
||||||
|
const mountedPaths = new Set<string>();
|
||||||
|
|
||||||
|
// First, add all entries from hostConfig.Binds (named volumes and bind mounts)
|
||||||
|
if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) {
|
||||||
|
for (const bind of hostConfig.Binds) {
|
||||||
|
volumeBinds.push(bind);
|
||||||
|
// Track the destination path to avoid duplicates
|
||||||
|
const parts = bind.split(':');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
mountedPaths.add(parts[1].split(':')[0]); // Handle "src:dest:ro" format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, add anonymous volumes from Mounts that aren't already in Binds
|
||||||
|
// These have Type: "volume" and a generated Name (hash), but no entry in Binds
|
||||||
|
const mounts = inspectData.Mounts || [];
|
||||||
|
for (const mount of mounts) {
|
||||||
|
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
||||||
|
// Skip if this destination is already covered by Binds
|
||||||
|
if (!mountedPaths.has(mount.Destination)) {
|
||||||
|
// Format: "volumeName:destination" or "volumeName:destination:ro"
|
||||||
|
const bindStr = mount.RW === false
|
||||||
|
? `${mount.Name}:${mount.Destination}:ro`
|
||||||
|
: `${mount.Name}:${mount.Destination}`;
|
||||||
|
volumeBinds.push(bindStr);
|
||||||
|
log?.(`Preserving anonymous volume: ${mount.Name} -> ${mount.Destination}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthcheck configuration
|
||||||
|
let healthcheck: any = undefined;
|
||||||
|
if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) {
|
||||||
|
// Skip if healthcheck is disabled (NONE)
|
||||||
|
if (config.Healthcheck.Test[0] !== 'NONE') {
|
||||||
|
healthcheck = {
|
||||||
|
test: config.Healthcheck.Test,
|
||||||
|
interval: config.Healthcheck.Interval,
|
||||||
|
timeout: config.Healthcheck.Timeout,
|
||||||
|
retries: config.Healthcheck.Retries,
|
||||||
|
startPeriod: config.Healthcheck.StartPeriod
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device mappings
|
||||||
|
const devices = (hostConfig.Devices || []).map((d: any) => ({
|
||||||
|
hostPath: d.PathOnHost || '',
|
||||||
|
containerPath: d.PathInContainer || '',
|
||||||
|
permissions: d.CgroupPermissions || 'rwm'
|
||||||
|
})).filter((d: any) => d.hostPath && d.containerPath);
|
||||||
|
|
||||||
|
// Ulimits
|
||||||
|
const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({
|
||||||
|
name: u.Name,
|
||||||
|
soft: u.Soft,
|
||||||
|
hard: u.Hard
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Extract network connections with aliases and static IPs
|
||||||
|
const networkSettings = inspectData.NetworkSettings?.Networks || {};
|
||||||
|
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
|
||||||
|
|
||||||
|
// Build network info for reconnection (including aliases, IPs, and gateway priority)
|
||||||
|
interface NetworkInfo {
|
||||||
|
name: string;
|
||||||
|
aliases: string[];
|
||||||
|
ipv4Address: string | undefined;
|
||||||
|
ipv6Address: string | undefined;
|
||||||
|
gwPriority: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract primary network aliases, static IP, and gateway priority (for createContainer)
|
||||||
|
let primaryNetworkAliases: string[] | undefined;
|
||||||
|
let primaryNetworkIpv4: string | undefined;
|
||||||
|
let primaryNetworkIpv6: string | undefined;
|
||||||
|
let primaryNetworkMacAddress: string | undefined;
|
||||||
|
let primaryNetworkGwPriority: number | undefined;
|
||||||
|
|
||||||
|
const additionalNetworks: NetworkInfo[] = [];
|
||||||
|
for (const [netName, netConfig] of Object.entries(networkSettings)) {
|
||||||
|
const netConf = netConfig as any;
|
||||||
|
|
||||||
|
// Check if this is the primary network
|
||||||
|
const isPrimary = netName === primaryNetwork ||
|
||||||
|
(primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default'));
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
// Extract primary network's aliases and static IP
|
||||||
|
// Filter out auto-generated aliases (container name and ID prefix)
|
||||||
|
// Note: Docker Compose stores aliases in both Aliases and DNSNames,
|
||||||
|
// but after container recreation Aliases may be null while DNSNames has the values
|
||||||
|
const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [];
|
||||||
|
const shortContainerId = container.id.substring(0, 12);
|
||||||
|
primaryNetworkAliases = allAliases.filter((a: string) =>
|
||||||
|
a !== containerName &&
|
||||||
|
a !== container.id &&
|
||||||
|
a !== shortContainerId
|
||||||
|
);
|
||||||
|
if (!primaryNetworkAliases || primaryNetworkAliases.length === 0) {
|
||||||
|
primaryNetworkAliases = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract static IP from IPAMConfig (user-configured) - don't use auto-assigned IPAddress
|
||||||
|
primaryNetworkIpv4 = netConf.IPAMConfig?.IPv4Address || undefined;
|
||||||
|
primaryNetworkIpv6 = netConf.IPAMConfig?.IPv6Address || undefined;
|
||||||
|
|
||||||
|
// Extract MAC address (only if explicitly set, not auto-generated)
|
||||||
|
// Auto-generated MACs start with 02:42, so we preserve all MACs
|
||||||
|
primaryNetworkMacAddress = netConf.MacAddress || undefined;
|
||||||
|
|
||||||
|
// Extract gateway priority (Docker Engine 28+)
|
||||||
|
// GwPriority determines which network provides the default gateway
|
||||||
|
primaryNetworkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0
|
||||||
|
? netConf.GwPriority : undefined;
|
||||||
|
|
||||||
|
if (primaryNetworkAliases?.length) {
|
||||||
|
log?.(`Primary network aliases: ${primaryNetworkAliases.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (primaryNetworkIpv4) {
|
||||||
|
log?.(`Primary network static IPv4: ${primaryNetworkIpv4}`);
|
||||||
|
}
|
||||||
|
if (primaryNetworkMacAddress) {
|
||||||
|
log?.(`Primary network MAC address: ${primaryNetworkMacAddress}`);
|
||||||
|
}
|
||||||
|
if (primaryNetworkGwPriority !== undefined) {
|
||||||
|
log?.(`Primary network gateway priority: ${primaryNetworkGwPriority}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Secondary network - add to reconnection list
|
||||||
|
// Use DNSNames as fallback for aliases (see comment above for primary network)
|
||||||
|
additionalNetworks.push({
|
||||||
|
name: netName,
|
||||||
|
aliases: (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [],
|
||||||
|
ipv4Address: netConf.IPAMConfig?.IPv4Address || undefined,
|
||||||
|
ipv6Address: netConf.IPAMConfig?.IPv6Address || undefined,
|
||||||
|
gwPriority: netConf.GwPriority !== undefined && netConf.GwPriority !== 0
|
||||||
|
? netConf.GwPriority : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalNetworks.length > 0) {
|
||||||
|
log?.(`Will reconnect to ${additionalNetworks.length} additional network(s): ${additionalNetworks.map(n => n.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log extra hosts if present
|
||||||
|
if (hostConfig.ExtraHosts?.length > 0) {
|
||||||
|
log?.(`Extra hosts: ${hostConfig.ExtraHosts.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log device requests if present (GPU, etc.)
|
||||||
|
if (hostConfig.DeviceRequests?.length > 0) {
|
||||||
|
for (const dr of hostConfig.DeviceRequests) {
|
||||||
|
const caps = dr.Capabilities?.flat().join(',') || 'none';
|
||||||
|
log?.(`Device request: driver=${dr.Driver || 'default'}, count=${dr.Count}, capabilities=[${caps}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new container with ALL preserved settings
|
||||||
|
log?.('Creating new container with preserved settings...');
|
||||||
const newContainer = await createContainer({
|
const newContainer = await createContainer({
|
||||||
name: containerName,
|
name: containerName,
|
||||||
image: config.Image,
|
image: config.Image,
|
||||||
ports,
|
|
||||||
volumeBinds: hostConfig.Binds || [],
|
// Command and entrypoint
|
||||||
|
cmd: config.Cmd || undefined,
|
||||||
|
entrypoint: config.Entrypoint || undefined,
|
||||||
|
workingDir: config.WorkingDir || undefined,
|
||||||
|
|
||||||
|
// Environment and labels
|
||||||
env: config.Env || [],
|
env: config.Env || [],
|
||||||
labels: config.Labels || {},
|
labels: config.Labels || {},
|
||||||
cmd: config.Cmd || undefined,
|
|
||||||
|
// Port mappings
|
||||||
|
ports: Object.keys(ports).length > 0 ? ports : undefined,
|
||||||
|
|
||||||
|
// Volume bindings (includes both named and anonymous volumes)
|
||||||
|
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
|
||||||
|
|
||||||
|
// Restart policy
|
||||||
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
||||||
networkMode: hostConfig.NetworkMode || undefined
|
restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount,
|
||||||
|
|
||||||
|
// Network mode and network-specific settings
|
||||||
|
networkMode: hostConfig.NetworkMode || undefined,
|
||||||
|
networkAliases: primaryNetworkAliases,
|
||||||
|
networkIpv4Address: primaryNetworkIpv4,
|
||||||
|
networkIpv6Address: primaryNetworkIpv6,
|
||||||
|
networkGwPriority: primaryNetworkGwPriority,
|
||||||
|
|
||||||
|
// User and hostname
|
||||||
|
user: config.User || undefined,
|
||||||
|
hostname: config.Hostname || undefined,
|
||||||
|
|
||||||
|
// Privileged mode
|
||||||
|
privileged: hostConfig.Privileged || undefined,
|
||||||
|
|
||||||
|
// Healthcheck
|
||||||
|
healthcheck,
|
||||||
|
|
||||||
|
// Terminal settings
|
||||||
|
tty: config.Tty || undefined,
|
||||||
|
stdinOpen: config.OpenStdin || undefined,
|
||||||
|
|
||||||
|
// Memory limits
|
||||||
|
memory: hostConfig.Memory || undefined,
|
||||||
|
memoryReservation: hostConfig.MemoryReservation || undefined,
|
||||||
|
memorySwap: hostConfig.MemorySwap || undefined,
|
||||||
|
|
||||||
|
// CPU limits
|
||||||
|
cpuShares: hostConfig.CpuShares || undefined,
|
||||||
|
cpuQuota: hostConfig.CpuQuota || undefined,
|
||||||
|
cpuPeriod: hostConfig.CpuPeriod || undefined,
|
||||||
|
nanoCpus: hostConfig.NanoCpus || undefined,
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined,
|
||||||
|
capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined,
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
devices: devices.length > 0 ? devices : undefined,
|
||||||
|
|
||||||
|
// DNS settings
|
||||||
|
dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined,
|
||||||
|
dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined,
|
||||||
|
dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined,
|
||||||
|
|
||||||
|
// Security options
|
||||||
|
securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined,
|
||||||
|
|
||||||
|
// Ulimits
|
||||||
|
ulimits: ulimits.length > 0 ? ulimits : undefined,
|
||||||
|
|
||||||
|
// Process and memory settings
|
||||||
|
oomKillDisable: hostConfig.OomKillDisable || undefined,
|
||||||
|
pidsLimit: hostConfig.PidsLimit || undefined,
|
||||||
|
shmSize: hostConfig.ShmSize || undefined,
|
||||||
|
|
||||||
|
// Tmpfs mounts
|
||||||
|
tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined,
|
||||||
|
|
||||||
|
// Sysctls
|
||||||
|
sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined,
|
||||||
|
|
||||||
|
// Logging configuration
|
||||||
|
logDriver: hostConfig.LogConfig?.Type || undefined,
|
||||||
|
logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0
|
||||||
|
? hostConfig.LogConfig.Config : undefined,
|
||||||
|
|
||||||
|
// Namespace settings
|
||||||
|
ipcMode: hostConfig.IpcMode || undefined,
|
||||||
|
pidMode: hostConfig.PidMode || undefined,
|
||||||
|
utsMode: hostConfig.UTSMode || undefined,
|
||||||
|
|
||||||
|
// Cgroup parent
|
||||||
|
cgroupParent: hostConfig.CgroupParent || undefined,
|
||||||
|
|
||||||
|
// Stop signal and timeout
|
||||||
|
stopSignal: config.StopSignal || undefined,
|
||||||
|
stopTimeout: config.StopTimeout || undefined,
|
||||||
|
|
||||||
|
// Init process
|
||||||
|
init: hostConfig.Init === true ? true : undefined,
|
||||||
|
|
||||||
|
// MAC address (from primary network settings)
|
||||||
|
macAddress: primaryNetworkMacAddress,
|
||||||
|
|
||||||
|
// Extra hosts (/etc/hosts entries)
|
||||||
|
extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined,
|
||||||
|
|
||||||
|
// Device requests (GPU access, etc.)
|
||||||
|
deviceRequests: hostConfig.DeviceRequests?.length > 0
|
||||||
|
? hostConfig.DeviceRequests.map((dr: any) => ({
|
||||||
|
driver: dr.Driver || undefined,
|
||||||
|
count: dr.Count,
|
||||||
|
deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined,
|
||||||
|
capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined,
|
||||||
|
options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Container runtime (critical for GPU containers using nvidia runtime)
|
||||||
|
runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined,
|
||||||
|
|
||||||
|
// Read-only root filesystem (security hardening)
|
||||||
|
readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined,
|
||||||
|
|
||||||
|
// CPU pinning
|
||||||
|
cpusetCpus: hostConfig.CpusetCpus || undefined,
|
||||||
|
|
||||||
|
// NUMA memory nodes
|
||||||
|
cpusetMems: hostConfig.CpusetMems || undefined,
|
||||||
|
|
||||||
|
// Additional groups
|
||||||
|
groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined,
|
||||||
|
|
||||||
|
// Memory swappiness (0-100)
|
||||||
|
memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined,
|
||||||
|
|
||||||
|
// User namespace mode
|
||||||
|
usernsMode: hostConfig.UsernsMode || undefined,
|
||||||
|
|
||||||
|
// Domain name
|
||||||
|
domainname: config.Domainname || undefined
|
||||||
}, envId);
|
}, envId);
|
||||||
|
|
||||||
|
// Reconnect to additional networks with aliases, static IPs, and gateway priority (before starting)
|
||||||
|
if (additionalNetworks.length > 0) {
|
||||||
|
log?.(`Reconnecting to ${additionalNetworks.length} additional network(s)...`);
|
||||||
|
for (const netInfo of additionalNetworks) {
|
||||||
|
try {
|
||||||
|
await connectContainerToNetwork(netInfo.name, newContainer.id, envId, {
|
||||||
|
aliases: netInfo.aliases.length > 0 ? netInfo.aliases : undefined,
|
||||||
|
ipv4Address: netInfo.ipv4Address,
|
||||||
|
ipv6Address: netInfo.ipv6Address,
|
||||||
|
gwPriority: netInfo.gwPriority
|
||||||
|
});
|
||||||
|
log?.(` Connected to: ${netInfo.name}`);
|
||||||
|
if (netInfo.aliases.length > 0) {
|
||||||
|
log?.(` Aliases: ${netInfo.aliases.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (netInfo.ipv4Address) {
|
||||||
|
log?.(` Static IPv4: ${netInfo.ipv4Address}`);
|
||||||
|
}
|
||||||
|
if (netInfo.gwPriority !== undefined) {
|
||||||
|
log?.(` Gateway priority: ${netInfo.gwPriority}`);
|
||||||
|
}
|
||||||
|
} catch (netError: any) {
|
||||||
|
log?.(` Warning: Failed to connect to network "${netInfo.name}": ${netError.message}`);
|
||||||
|
// Don't fail the entire update for network connection issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start if was running
|
// Start if was running
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
log?.('Starting new container...');
|
log?.('Starting new container...');
|
||||||
await newContainer.start();
|
await newContainer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
log?.('Container recreated successfully');
|
log?.('Container recreated successfully with all settings preserved');
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
log?.(`Failed to recreate container: ${error.message}`);
|
log?.(`Failed to recreate container: ${error.message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a container that is part of a Docker Compose stack.
|
||||||
|
* Uses `docker compose up -d` which preserves ALL configuration from the compose file.
|
||||||
|
*
|
||||||
|
* @param stackName - The compose project name (com.docker.compose.project label)
|
||||||
|
* @param serviceName - The service name within the stack (com.docker.compose.service label)
|
||||||
|
* @param envId - Optional environment ID
|
||||||
|
* @param log - Optional logging function
|
||||||
|
* @returns true if update succeeded, false if stack not found (use fallback)
|
||||||
|
*/
|
||||||
|
export async function updateStackContainer(
|
||||||
|
stackName: string,
|
||||||
|
serviceName: string,
|
||||||
|
envId?: number,
|
||||||
|
log?: (msg: string) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
log?.(`Looking up stack configuration for: ${stackName}`);
|
||||||
|
|
||||||
|
// Check if we have the compose file for this stack
|
||||||
|
const composeResult = await getStackComposeFile(stackName, envId);
|
||||||
|
|
||||||
|
if (!composeResult.success || !composeResult.content) {
|
||||||
|
// Stack is "external" - we don't have the compose file
|
||||||
|
log?.(`WARNING: No compose file found for stack "${stackName}"`);
|
||||||
|
log?.(`This stack may have been created outside Dockhand`);
|
||||||
|
log?.(`Falling back to container recreation (some settings may be lost)`);
|
||||||
|
log?.(`TIP: Import the stack in Dockhand to preserve all settings on future updates`);
|
||||||
|
return false; // Signal to use fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.(`Found compose file for stack: ${stackName}`);
|
||||||
|
log?.(`Running: docker compose up -d (service: ${serviceName})`);
|
||||||
|
|
||||||
|
// Use startStack which runs `docker compose up -d`
|
||||||
|
// This will recreate only containers with changed images
|
||||||
|
const result = await startStack(stackName, envId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log?.(`Stack updated successfully via docker compose`);
|
||||||
|
if (result.output) {
|
||||||
|
// Log compose output (shows which containers were recreated)
|
||||||
|
const lines = result.output.split('\n').filter((l: string) => l.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
log?.(`[compose] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log?.(`docker compose up failed: ${result.error || 'Unknown error'}`);
|
||||||
|
if (result.output) {
|
||||||
|
log?.(`Output: ${result.output}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
log?.(`Stack update error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,8 +256,9 @@ export async function adoptStack(
|
|||||||
|
|
||||||
return { success: true, adoptedName: finalName };
|
return { success: true, adoptedName: finalName };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, errorMsg);
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -735,7 +735,8 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr
|
|||||||
console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`);
|
console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${logPrefix} Error logging into registry ${reg.name}:`, e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`${logPrefix} Error logging into registry ${reg.name}:`, errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,8 @@ class SubprocessManager {
|
|||||||
message: notifMessage,
|
message: notifMessage,
|
||||||
type: notificationType
|
type: notificationType
|
||||||
}, image).catch((err) => {
|
}, image).catch((err) => {
|
||||||
console.error('[SubprocessManager] Failed to send notification:', err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[SubprocessManager] Failed to send notification:', errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -369,7 +370,8 @@ class SubprocessManager {
|
|||||||
},
|
},
|
||||||
message.envId
|
message.envId
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
console.error('[SubprocessManager] Failed to send online notification:', err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[SubprocessManager] Failed to send online notification:', errorMsg);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await sendEventNotification(
|
await sendEventNotification(
|
||||||
@@ -381,7 +383,8 @@ class SubprocessManager {
|
|||||||
},
|
},
|
||||||
message.envId
|
message.envId
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
console.error('[SubprocessManager] Failed to send offline notification:', err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[SubprocessManager] Failed to send offline notification:', errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import { authorize } from '$lib/server/authorize';
|
|||||||
import {
|
import {
|
||||||
listContainers,
|
listContainers,
|
||||||
inspectContainer,
|
inspectContainer,
|
||||||
stopContainer,
|
|
||||||
removeContainer,
|
|
||||||
createContainer,
|
|
||||||
pullImage,
|
pullImage,
|
||||||
getTempImageTag,
|
getTempImageTag,
|
||||||
isDigestBasedImage,
|
isDigestBasedImage,
|
||||||
@@ -18,6 +15,7 @@ import { auditContainer } from '$lib/server/audit';
|
|||||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||||
|
import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
critical: number;
|
critical: number;
|
||||||
@@ -401,81 +399,115 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
} catch { /* ignore cleanup errors */ }
|
} catch { /* ignore cleanup errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Stop container if running
|
// Detect if container is part of a Docker Compose stack
|
||||||
if (wasRunning) {
|
const containerLabels = config.Labels || {};
|
||||||
|
const composeProject = containerLabels['com.docker.compose.project'];
|
||||||
|
const composeService = containerLabels['com.docker.compose.service'];
|
||||||
|
const isStackContainer = !!composeProject;
|
||||||
|
|
||||||
|
// Progress logging function for shared functions
|
||||||
|
const logProgress = (message: string) => {
|
||||||
safeEnqueue({
|
safeEnqueue({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
containerId,
|
containerId,
|
||||||
containerName,
|
containerName,
|
||||||
step: 'stopping',
|
step: 'creating',
|
||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: containerIds.length,
|
total: containerIds.length,
|
||||||
message: `Stopping ${containerName}...`
|
message
|
||||||
});
|
});
|
||||||
await stopContainer(containerId, envIdNum);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Remove old container
|
let updateSuccess = false;
|
||||||
safeEnqueue({
|
let newContainerId = containerId;
|
||||||
type: 'progress',
|
|
||||||
containerId,
|
|
||||||
containerName,
|
|
||||||
step: 'removing',
|
|
||||||
current: i + 1,
|
|
||||||
total: containerIds.length,
|
|
||||||
message: `Removing old container ${containerName}...`
|
|
||||||
});
|
|
||||||
await removeContainer(containerId, true, envIdNum);
|
|
||||||
|
|
||||||
// Prepare port bindings
|
if (isStackContainer) {
|
||||||
const ports: { [key: string]: { HostPort: string } } = {};
|
// ===================================================================
|
||||||
if (hostConfig.PortBindings) {
|
// STACK CONTAINER: Use docker compose up -d to preserve ALL settings
|
||||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
// ===================================================================
|
||||||
if (bindings && (bindings as any[]).length > 0) {
|
safeEnqueue({
|
||||||
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
|
type: 'progress',
|
||||||
|
containerId,
|
||||||
|
containerName,
|
||||||
|
step: 'creating',
|
||||||
|
current: i + 1,
|
||||||
|
total: containerIds.length,
|
||||||
|
message: `Updating stack ${composeProject} (service: ${composeService})...`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try stack-based update first
|
||||||
|
const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum, logProgress);
|
||||||
|
|
||||||
|
if (stackSuccess) {
|
||||||
|
updateSuccess = true;
|
||||||
|
// Find the new container ID
|
||||||
|
const updatedContainers = await listContainers(true, envIdNum);
|
||||||
|
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||||
|
if (updatedContainer) {
|
||||||
|
newContainerId = updatedContainer.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Stack is external, use container recreation with full settings
|
||||||
|
safeEnqueue({
|
||||||
|
type: 'progress',
|
||||||
|
containerId,
|
||||||
|
containerName,
|
||||||
|
step: 'creating',
|
||||||
|
current: i + 1,
|
||||||
|
total: containerIds.length,
|
||||||
|
message: `Recreating ${containerName} (external stack, preserving all settings)...`
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSuccess = await recreateContainer(containerName, envIdNum, logProgress);
|
||||||
|
if (updateSuccess) {
|
||||||
|
const updatedContainers = await listContainers(true, envIdNum);
|
||||||
|
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||||
|
if (updatedContainer) {
|
||||||
|
newContainerId = updatedContainer.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ===================================================================
|
||||||
|
// STANDALONE CONTAINER: Use shared recreation with ALL settings
|
||||||
|
// ===================================================================
|
||||||
|
safeEnqueue({
|
||||||
|
type: 'progress',
|
||||||
|
containerId,
|
||||||
|
containerName,
|
||||||
|
step: 'creating',
|
||||||
|
current: i + 1,
|
||||||
|
total: containerIds.length,
|
||||||
|
message: `Recreating ${containerName} (preserving all settings)...`
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSuccess = await recreateContainer(containerName, envIdNum, logProgress);
|
||||||
|
if (updateSuccess) {
|
||||||
|
const updatedContainers = await listContainers(true, envIdNum);
|
||||||
|
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||||
|
if (updatedContainer) {
|
||||||
|
newContainerId = updatedContainer.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Create new container
|
if (!updateSuccess) {
|
||||||
safeEnqueue({
|
|
||||||
type: 'progress',
|
|
||||||
containerId,
|
|
||||||
containerName,
|
|
||||||
step: 'creating',
|
|
||||||
current: i + 1,
|
|
||||||
total: containerIds.length,
|
|
||||||
message: `Creating new container ${containerName}...`
|
|
||||||
});
|
|
||||||
|
|
||||||
const newContainer = await createContainer({
|
|
||||||
name: containerName,
|
|
||||||
image: imageName,
|
|
||||||
ports,
|
|
||||||
volumeBinds: hostConfig.Binds || [],
|
|
||||||
env: config.Env || [],
|
|
||||||
labels: config.Labels || {},
|
|
||||||
cmd: config.Cmd || undefined,
|
|
||||||
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
|
||||||
networkMode: hostConfig.NetworkMode || undefined
|
|
||||||
}, envIdNum);
|
|
||||||
|
|
||||||
// Step 6: Start if was running
|
|
||||||
if (wasRunning) {
|
|
||||||
safeEnqueue({
|
safeEnqueue({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
containerId,
|
containerId,
|
||||||
containerName,
|
containerName,
|
||||||
step: 'starting',
|
step: 'failed',
|
||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: containerIds.length,
|
total: containerIds.length,
|
||||||
message: `Starting ${containerName}...`
|
success: false,
|
||||||
|
error: 'Container recreation failed'
|
||||||
});
|
});
|
||||||
await newContainer.start();
|
failCount++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
|
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||||
|
|
||||||
// Done with this container - use original containerId for UI consistency
|
// Done with this container - use original containerId for UI consistency
|
||||||
safeEnqueue({
|
safeEnqueue({
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import {
|
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
|
||||||
listContainers,
|
|
||||||
inspectContainer,
|
|
||||||
stopContainer,
|
|
||||||
removeContainer,
|
|
||||||
createContainer,
|
|
||||||
pullImage
|
|
||||||
} from '$lib/server/docker';
|
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||||
|
|
||||||
export interface BatchUpdateResult {
|
export interface BatchUpdateResult {
|
||||||
containerId: string;
|
containerId: string;
|
||||||
@@ -20,6 +14,8 @@ export interface BatchUpdateResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch update containers by recreating them with latest images.
|
* Batch update containers by recreating them with latest images.
|
||||||
|
* Preserves ALL container settings including health checks, resource limits,
|
||||||
|
* capabilities, DNS, security options, ulimits, and network connections.
|
||||||
* Expects JSON body: { containerIds: string[] }
|
* Expects JSON body: { containerIds: string[] }
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
@@ -62,9 +58,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
|
|
||||||
// Get full container config
|
// Get full container config
|
||||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||||
const wasRunning = inspectData.State.Running;
|
|
||||||
const config = inspectData.Config;
|
const config = inspectData.Config;
|
||||||
const hostConfig = inspectData.HostConfig;
|
|
||||||
const imageName = config.Image;
|
const imageName = config.Image;
|
||||||
const containerName = container.name;
|
const containerName = container.name;
|
||||||
|
|
||||||
@@ -81,47 +75,65 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop container if running
|
// Detect if container is part of a Docker Compose stack
|
||||||
if (wasRunning) {
|
const containerLabels = config.Labels || {};
|
||||||
await stopContainer(containerId, envIdNum);
|
const composeProject = containerLabels['com.docker.compose.project'];
|
||||||
}
|
const composeService = containerLabels['com.docker.compose.service'];
|
||||||
|
const isStackContainer = !!composeProject;
|
||||||
|
|
||||||
// Remove old container
|
let updateSuccess = false;
|
||||||
await removeContainer(containerId, true, envIdNum);
|
let newContainerId = containerId;
|
||||||
|
|
||||||
// Prepare port bindings
|
if (isStackContainer) {
|
||||||
const ports: { [key: string]: { HostPort: string } } = {};
|
// Stack container: Try docker compose up -d first
|
||||||
if (hostConfig.PortBindings) {
|
const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum);
|
||||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
|
||||||
if (bindings && (bindings as any[]).length > 0) {
|
if (stackSuccess) {
|
||||||
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
|
updateSuccess = true;
|
||||||
|
// Find the new container ID
|
||||||
|
const updatedContainers = await listContainers(true, envIdNum);
|
||||||
|
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||||
|
if (updatedContainer) {
|
||||||
|
newContainerId = updatedContainer.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Stack is external, use container recreation
|
||||||
|
updateSuccess = await recreateContainer(containerName, envIdNum);
|
||||||
|
if (updateSuccess) {
|
||||||
|
const updatedContainers = await listContainers(true, envIdNum);
|
||||||
|
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||||
|
if (updatedContainer) {
|
||||||
|
newContainerId = updatedContainer.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standalone container: Use shared recreation with ALL settings
|
||||||
|
updateSuccess = await recreateContainer(containerName, envIdNum);
|
||||||
|
if (updateSuccess) {
|
||||||
|
const updatedContainers = await listContainers(true, envIdNum);
|
||||||
|
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||||
|
if (updatedContainer) {
|
||||||
|
newContainerId = updatedContainer.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new container
|
if (!updateSuccess) {
|
||||||
const newContainer = await createContainer({
|
results.push({
|
||||||
name: containerName,
|
containerId,
|
||||||
image: imageName,
|
containerName,
|
||||||
ports,
|
success: false,
|
||||||
volumeBinds: hostConfig.Binds || [],
|
error: 'Container recreation failed'
|
||||||
env: config.Env || [],
|
});
|
||||||
labels: config.Labels || {},
|
continue;
|
||||||
cmd: config.Cmd || undefined,
|
|
||||||
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
|
||||||
networkMode: hostConfig.NetworkMode || undefined
|
|
||||||
}, envIdNum);
|
|
||||||
|
|
||||||
// Start if was running
|
|
||||||
if (wasRunning) {
|
|
||||||
await newContainer.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
|
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
containerId: newContainer.id,
|
containerId: newContainerId,
|
||||||
containerName,
|
containerName,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { inspectImage, tagImage, pushImage } from '$lib/server/docker';
|
import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker';
|
||||||
import { getRegistry, getEnvironment } from '$lib/server/db';
|
import { getRegistry, getEnvironment } from '$lib/server/db';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditImage } from '$lib/server/audit';
|
import { auditImage } from '$lib/server/audit';
|
||||||
@@ -69,8 +69,8 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the target tag
|
// Build the target tag
|
||||||
const registryUrl = new URL(registry.url);
|
// Parse registry URL to get host and org path separately
|
||||||
const registryHost = registryUrl.host;
|
const { host: registryHost, fullRegistry } = parseRegistryUrl(registry.url);
|
||||||
|
|
||||||
// Check if this is Docker Hub
|
// Check if this is Docker Hub
|
||||||
const isDockerHub = registryHost.includes('docker.io') ||
|
const isDockerHub = registryHost.includes('docker.io') ||
|
||||||
@@ -81,7 +81,8 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
// Use custom tag if provided, otherwise use the base image name
|
// Use custom tag if provided, otherwise use the base image name
|
||||||
const targetImageName = newTag || baseImageName;
|
const targetImageName = newTag || baseImageName;
|
||||||
// Docker Hub doesn't need host prefix - just username/image:tag
|
// Docker Hub doesn't need host prefix - just username/image:tag
|
||||||
const targetTag = isDockerHub ? targetImageName : `${registryHost}/${targetImageName}`;
|
// For other registries, use full registry path including org (e.g., registry.example.com/org/image:tag)
|
||||||
|
const targetTag = isDockerHub ? targetImageName : `${fullRegistry}/${targetImageName}`;
|
||||||
|
|
||||||
// Parse repo and tag properly (handle registry:port/image:tag format)
|
// Parse repo and tag properly (handle registry:port/image:tag format)
|
||||||
// Find the last colon that's after the last slash (that's the tag separator)
|
// Find the last colon that's after the last slash (that's the tag separator)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
|
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
|
const { baseUrl, orgPath, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
|
||||||
|
|
||||||
// Build catalog URL with pagination
|
// Build catalog URL with pagination
|
||||||
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
|
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
|
||||||
@@ -58,7 +58,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// The V2 API returns { repositories: [...] }
|
// The V2 API returns { repositories: [...] }
|
||||||
const repositories: string[] = data.repositories || [];
|
let repositories: string[] = data.repositories || [];
|
||||||
|
|
||||||
|
// If the registry URL has an organization path, filter to only show repos under that path
|
||||||
|
if (orgPath) {
|
||||||
|
const orgPrefix = orgPath.replace(/^\//, ''); // Remove leading slash
|
||||||
|
repositories = repositories.filter(repo => repo.startsWith(orgPrefix + '/') || repo === orgPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse Link header for pagination
|
// Parse Link header for pagination
|
||||||
// Format: </v2/_catalog?last=xxx&n=100>; rel="next"
|
// Format: </v2/_catalog?last=xxx&n=100>; rel="next"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const DELETE: RequestHandler = async ({ url }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
|
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
|
||||||
|
// Note: orgPath is not used here because imageName already contains the full repo path
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ async function searchPrivateRegistry(registry: any, term: string, limit: number)
|
|||||||
// Try to directly check if an image exists by querying its tags endpoint
|
// Try to directly check if an image exists by querying its tags endpoint
|
||||||
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
|
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
// Note: orgPath is not used here because imageName already contains the full repo path
|
||||||
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
|
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
@@ -104,6 +105,7 @@ async function tryDirectImageLookup(registry: any, imageName: string): Promise<b
|
|||||||
|
|
||||||
// Search through catalog (slow for large registries, limited to first few pages)
|
// Search through catalog (slow for large registries, limited to first few pages)
|
||||||
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
|
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
|
||||||
|
// Note: orgPath could be used here to filter results, but search is already term-based
|
||||||
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
|
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
|
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
|
||||||
|
// Note: orgPath is not used here because imageName already contains the full repo path
|
||||||
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
|
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
|
||||||
const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`;
|
const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||||
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
||||||
import { currentEnvironment } from '$lib/stores/environment';
|
import { currentEnvironment } from '$lib/stores/environment';
|
||||||
import { Ship, Cable, ExternalLink, AlertTriangle } from 'lucide-svelte';
|
import { Ship, Cable, ExternalLink, AlertTriangle, Info, Layers } from 'lucide-svelte';
|
||||||
import type { SystemContainerType } from '$lib/types';
|
import type { SystemContainerType } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
cronExpression: string;
|
cronExpression: string;
|
||||||
vulnerabilityCriteria: VulnerabilityCriteria;
|
vulnerabilityCriteria: VulnerabilityCriteria;
|
||||||
systemContainer?: SystemContainerType | null;
|
systemContainer?: SystemContainerType | null;
|
||||||
|
isComposeContainer?: boolean;
|
||||||
|
composeStackName?: string;
|
||||||
onenablechange?: (enabled: boolean) => void;
|
onenablechange?: (enabled: boolean) => void;
|
||||||
oncronchange?: (cron: string) => void;
|
oncronchange?: (cron: string) => void;
|
||||||
oncriteriachange?: (criteria: VulnerabilityCriteria) => void;
|
oncriteriachange?: (criteria: VulnerabilityCriteria) => void;
|
||||||
@@ -22,6 +24,8 @@
|
|||||||
cronExpression = $bindable(),
|
cronExpression = $bindable(),
|
||||||
vulnerabilityCriteria = $bindable(),
|
vulnerabilityCriteria = $bindable(),
|
||||||
systemContainer = null,
|
systemContainer = null,
|
||||||
|
isComposeContainer = false,
|
||||||
|
composeStackName = '',
|
||||||
onenablechange,
|
onenablechange,
|
||||||
oncronchange,
|
oncronchange,
|
||||||
oncriteriachange
|
oncriteriachange
|
||||||
@@ -93,6 +97,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isComposeContainer && enabled}
|
||||||
|
<div class="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
|
||||||
|
<Layers class="mt-0.5 h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||||
|
<div class="text-xs text-blue-800 dark:text-blue-200">
|
||||||
|
<p class="font-medium">Stack container update behavior</p>
|
||||||
|
<p class="mt-1 text-blue-700 dark:text-blue-300">
|
||||||
|
This container is part of the <strong>{composeStackName}</strong> stack.
|
||||||
|
Updates will use <code class="rounded bg-blue-100 px-1 dark:bg-blue-900">docker compose up -d</code>
|
||||||
|
to preserve all configuration from the compose file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if enabled}
|
{#if enabled}
|
||||||
<CronEditor
|
<CronEditor
|
||||||
value={cronExpression}
|
value={cronExpression}
|
||||||
|
|||||||
@@ -114,6 +114,9 @@
|
|||||||
autoUpdateEnabled: boolean;
|
autoUpdateEnabled: boolean;
|
||||||
autoUpdateCronExpression: string;
|
autoUpdateCronExpression: string;
|
||||||
vulnerabilityCriteria: VulnerabilityCriteria;
|
vulnerabilityCriteria: VulnerabilityCriteria;
|
||||||
|
// Compose stack info
|
||||||
|
isComposeContainer?: boolean;
|
||||||
|
composeStackName?: string;
|
||||||
// Config sets
|
// Config sets
|
||||||
configSets: ConfigSet[];
|
configSets: ConfigSet[];
|
||||||
selectedConfigSetId: string;
|
selectedConfigSetId: string;
|
||||||
@@ -170,6 +173,8 @@
|
|||||||
autoUpdateEnabled = $bindable(),
|
autoUpdateEnabled = $bindable(),
|
||||||
autoUpdateCronExpression = $bindable(),
|
autoUpdateCronExpression = $bindable(),
|
||||||
vulnerabilityCriteria = $bindable(),
|
vulnerabilityCriteria = $bindable(),
|
||||||
|
isComposeContainer = false,
|
||||||
|
composeStackName = '',
|
||||||
configSets,
|
configSets,
|
||||||
selectedConfigSetId = $bindable(),
|
selectedConfigSetId = $bindable(),
|
||||||
errors = $bindable(),
|
errors = $bindable(),
|
||||||
@@ -1273,6 +1278,8 @@
|
|||||||
bind:cronExpression={autoUpdateCronExpression}
|
bind:cronExpression={autoUpdateCronExpression}
|
||||||
bind:vulnerabilityCriteria={vulnerabilityCriteria}
|
bind:vulnerabilityCriteria={vulnerabilityCriteria}
|
||||||
systemContainer={detectSystemContainer(image)}
|
systemContainer={detectSystemContainer(image)}
|
||||||
|
{isComposeContainer}
|
||||||
|
{composeStackName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,6 +1004,8 @@
|
|||||||
bind:autoUpdateEnabled
|
bind:autoUpdateEnabled
|
||||||
bind:autoUpdateCronExpression
|
bind:autoUpdateCronExpression
|
||||||
bind:vulnerabilityCriteria
|
bind:vulnerabilityCriteria
|
||||||
|
{isComposeContainer}
|
||||||
|
{composeStackName}
|
||||||
{configSets}
|
{configSets}
|
||||||
bind:selectedConfigSetId
|
bind:selectedConfigSetId
|
||||||
bind:errors
|
bind:errors
|
||||||
|
|||||||
@@ -64,8 +64,10 @@
|
|||||||
if (isDockerHub(targetRegistry)) {
|
if (isDockerHub(targetRegistry)) {
|
||||||
return tag;
|
return tag;
|
||||||
}
|
}
|
||||||
const host = new URL(targetRegistry.url).host;
|
// Include both host and path (e.g., registry.example.com/organization)
|
||||||
return `${host}/${tag}`;
|
const url = new URL(targetRegistry.url);
|
||||||
|
const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '');
|
||||||
|
return `${hostWithPath}/${tag}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isProcessing = $derived(pushStatus === 'pushing');
|
const isProcessing = $derived(pushStatus === 'pushing');
|
||||||
|
|||||||
@@ -80,16 +80,20 @@
|
|||||||
const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`;
|
const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`;
|
||||||
if (sourceRegistry && !isDockerHub(sourceRegistry)) {
|
if (sourceRegistry && !isDockerHub(sourceRegistry)) {
|
||||||
const urlObj = new URL(sourceRegistry.url);
|
const urlObj = new URL(sourceRegistry.url);
|
||||||
return `${urlObj.host}/${imageWithTag}`;
|
// Include both host and path (e.g., registry.example.com/organization)
|
||||||
|
const hostWithPath = urlObj.host + (urlObj.pathname !== '/' ? urlObj.pathname.replace(/\/$/, '') : '');
|
||||||
|
return `${hostWithPath}/${imageWithTag}`;
|
||||||
}
|
}
|
||||||
return imageWithTag;
|
return imageWithTag;
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetImageName = $derived(() => {
|
const targetImageName = $derived(() => {
|
||||||
if (!targetRegistryId || !targetRegistry) return customTag || 'image:latest';
|
if (!targetRegistryId || !targetRegistry) return customTag || 'image:latest';
|
||||||
const host = new URL(targetRegistry.url).host;
|
// Include both host and path (e.g., registry.example.com/organization)
|
||||||
|
const url = new URL(targetRegistry.url);
|
||||||
|
const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '');
|
||||||
const tag = customTag ? (customTag.includes(':') ? customTag : customTag + ':latest') : 'image:latest';
|
const tag = customTag ? (customTag.includes(':') ? customTag : customTag + ':latest') : 'image:latest';
|
||||||
return `${host}/${tag}`;
|
return `${hostWithPath}/${tag}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || pushStatus === 'pushing');
|
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || pushStatus === 'pushing');
|
||||||
|
|||||||
+16
-6
@@ -791,21 +791,31 @@ async function handleHawserMessage(ws: any, msg: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple token validation (in production this would use argon2 verification)
|
// Token validation using proper Argon2id verification (same as production)
|
||||||
// For dev mode, just check if a token exists for any environment
|
|
||||||
const tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all() as any[];
|
const tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all() as any[];
|
||||||
|
|
||||||
// For dev mode, accept any valid token format and use the first environment with a token
|
// Validate token using Argon2id hash verification
|
||||||
const token = tokens.find((t: any) => msg.token && msg.token.startsWith(t.token_prefix.slice(0, 4)));
|
let matchedToken: any = null;
|
||||||
|
for (const t of tokens) {
|
||||||
|
try {
|
||||||
|
const isValid = await Bun.password.verify(msg.token, t.token);
|
||||||
|
if (isValid) {
|
||||||
|
matchedToken = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If verification fails, continue to next token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!matchedToken) {
|
||||||
console.log('[Hawser WS] Invalid token');
|
console.log('[Hawser WS] Invalid token');
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' }));
|
ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' }));
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const environmentId = token.environment_id;
|
const environmentId = matchedToken.environment_id;
|
||||||
|
|
||||||
// Update environment with agent info
|
// Update environment with agent info
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user