1.0.13
This commit is contained in:
1 parent
a88d3d5788
commit
8f3a7eb435
96 files changed
+5235
-1870
No files matched your search
@@ -207,6 +207,24 @@ export async function auditUser(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for role actions
|
||||
*/
|
||||
export async function auditRole(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
roleId: number,
|
||||
roleName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'role', {
|
||||
entityId: String(roleId),
|
||||
entityName: roleName,
|
||||
description: `Role ${roleName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for settings actions
|
||||
*/
|
||||
@@ -261,6 +279,134 @@ export async function auditRegistry(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for git repository actions
|
||||
*/
|
||||
export async function auditGitRepository(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
repositoryId: number,
|
||||
repositoryName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'git_repository', {
|
||||
entityId: String(repositoryId),
|
||||
entityName: repositoryName,
|
||||
description: `Git repository ${repositoryName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for git credential actions
|
||||
*/
|
||||
export async function auditGitCredential(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
credentialId: number,
|
||||
credentialName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'git_credential', {
|
||||
entityId: String(credentialId),
|
||||
entityName: credentialName,
|
||||
description: `Git credential ${credentialName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for config set actions
|
||||
*/
|
||||
export async function auditConfigSet(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
configSetId: number,
|
||||
configSetName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'config_set', {
|
||||
entityId: String(configSetId),
|
||||
entityName: configSetName,
|
||||
description: `Config set ${configSetName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for notification channel actions
|
||||
*/
|
||||
export async function auditNotification(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
notificationId: number,
|
||||
notificationName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'notification', {
|
||||
entityId: String(notificationId),
|
||||
entityName: notificationName,
|
||||
description: `Notification channel ${notificationName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for OIDC provider actions
|
||||
*/
|
||||
export async function auditOidcProvider(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
providerId: number,
|
||||
providerName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'oidc_provider', {
|
||||
entityId: String(providerId),
|
||||
entityName: providerName,
|
||||
description: `OIDC provider ${providerName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for LDAP config actions
|
||||
*/
|
||||
export async function auditLdapConfig(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
configId: number,
|
||||
configName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'ldap_config', {
|
||||
entityId: String(configId),
|
||||
entityName: configName,
|
||||
description: `LDAP config ${configName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for git stack actions
|
||||
*/
|
||||
export async function auditGitStack(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
stackId: number,
|
||||
stackName: string,
|
||||
environmentId?: number | null,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'git_stack', {
|
||||
entityId: String(stackId),
|
||||
entityName: stackName,
|
||||
environmentId,
|
||||
description: `Git stack ${stackName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for auth actions (login/logout)
|
||||
*/
|
||||
|
||||
+76
-5
@@ -382,14 +382,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
fontSize: string;
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
}> {
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont] = await Promise.all([
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
|
||||
getUserSetting(userId, 'light_theme'),
|
||||
getUserSetting(userId, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
getUserSetting(userId, 'font_size'),
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font')
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -397,13 +399,14 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
font: font || 'system',
|
||||
fontSize: fontSize || 'normal',
|
||||
gridFontSize: gridFontSize || 'normal',
|
||||
terminalFont: terminalFont || 'system-mono'
|
||||
terminalFont: terminalFont || 'system-mono',
|
||||
editorFont: editorFont || 'system-mono'
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUserThemePreferences(
|
||||
userId: number,
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string }
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
|
||||
): Promise<void> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -424,6 +427,9 @@ export async function setUserThemePreferences(
|
||||
if (prefs.terminalFont !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'terminal_font', prefs.terminalFont));
|
||||
}
|
||||
if (prefs.editorFont !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
|
||||
}
|
||||
await Promise.all(updates);
|
||||
}
|
||||
|
||||
@@ -803,6 +809,8 @@ export const NOTIFICATION_EVENT_TYPES = [
|
||||
{ id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable', group: 'system', scope: 'environment' },
|
||||
{ id: 'environment_online', label: 'Environment online', description: 'Environment came back online', group: 'system', scope: 'environment' },
|
||||
{ id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold', group: 'system', scope: 'environment' },
|
||||
{ id: 'image_prune_success', label: 'Image prune success', description: 'Scheduled image prune completed successfully', group: 'system', scope: 'environment' },
|
||||
{ id: 'image_prune_failed', label: 'Image prune failed', description: 'Scheduled image prune failed', group: 'system', scope: 'environment' },
|
||||
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon (global)', group: 'system', scope: 'system' }
|
||||
] as const;
|
||||
|
||||
@@ -2941,7 +2949,8 @@ export type AuditAction =
|
||||
|
||||
export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
| 'user' | 'settings' | 'environment' | 'registry';
|
||||
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
|
||||
|
||||
export interface AuditLogData {
|
||||
id: number;
|
||||
@@ -4160,6 +4169,68 @@ export async function getAllEnvUpdateCheckSettings(): Promise<Array<{ envId: num
|
||||
return results;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMAGE PRUNE SCHEDULE SETTINGS
|
||||
// =============================================================================
|
||||
|
||||
export interface ImagePruneSettings {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
pruneMode: 'dangling' | 'all';
|
||||
lastPruned?: string;
|
||||
lastResult?: {
|
||||
spaceReclaimed: number;
|
||||
imagesRemoved: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getImagePruneSettings(envId: number): Promise<ImagePruneSettings | null> {
|
||||
const key = `env_${envId}_image_prune`;
|
||||
const result = await db.select().from(settings).where(eq(settings.key, key));
|
||||
if (!result[0]) return null;
|
||||
try {
|
||||
return JSON.parse(result[0].value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setImagePruneSettings(envId: number, config: ImagePruneSettings): Promise<void> {
|
||||
const key = `env_${envId}_image_prune`;
|
||||
const value = JSON.stringify(config);
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, key));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await db.insert(settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteImagePruneSettings(envId: number): Promise<void> {
|
||||
const key = `env_${envId}_image_prune`;
|
||||
await db.delete(settings).where(eq(settings.key, key));
|
||||
}
|
||||
|
||||
export async function getAllImagePruneSettings(): Promise<Array<{ envId: number; settings: ImagePruneSettings }>> {
|
||||
const rows = await db.select().from(settings).where(sql`${settings.key} LIKE 'env_%_image_prune'`);
|
||||
const results: Array<{ envId: number; settings: ImagePruneSettings }> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const match = row.key.match(/^env_(\d+)_image_prune$/);
|
||||
if (!match) continue;
|
||||
const envId = parseInt(match[1]);
|
||||
const config = JSON.parse(row.value) as ImagePruneSettings;
|
||||
// Return all settings, not just enabled ones (UI needs to show disabled schedules too)
|
||||
results.push({ envId, settings: config });
|
||||
} catch {
|
||||
// Skip invalid entries
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENVIRONMENT TIMEZONE SETTINGS
|
||||
// =============================================================================
|
||||
|
||||
+311
-2
@@ -1299,17 +1299,320 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
||||
}
|
||||
|
||||
export async function updateContainer(id: string, options: CreateContainerOptions, startAfterUpdate = false, envId?: number | null) {
|
||||
/**
|
||||
* Extract all container options from Docker inspect data.
|
||||
* This preserves ALL container settings for recreation.
|
||||
* Used by both updateContainer and recreateContainer to ensure consistency.
|
||||
*/
|
||||
export function extractContainerOptions(inspectData: any): CreateContainerOptions {
|
||||
const config = inspectData.Config;
|
||||
const hostConfig = inspectData.HostConfig;
|
||||
const name = inspectData.Name?.replace(/^\//, '') || '';
|
||||
|
||||
// Port bindings - preserve all host port mappings including HostIp
|
||||
const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {};
|
||||
if (hostConfig.PortBindings) {
|
||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
||||
if (bindings && (bindings as any[]).length > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Volume bindings - preserve ALL volumes including anonymous volumes
|
||||
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);
|
||||
const parts = bind.split(':');
|
||||
if (parts.length >= 2) {
|
||||
mountedPaths.add(parts[1].split(':')[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then, add anonymous volumes from Mounts that aren't already in Binds
|
||||
const mounts = inspectData.Mounts || [];
|
||||
for (const mount of mounts) {
|
||||
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
||||
if (!mountedPaths.has(mount.Destination)) {
|
||||
const bindStr = mount.RW === false
|
||||
? `${mount.Name}:${mount.Destination}:ro`
|
||||
: `${mount.Name}:${mount.Destination}`;
|
||||
volumeBinds.push(bindStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Healthcheck configuration
|
||||
let healthcheck: HealthcheckConfig | undefined = undefined;
|
||||
if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) {
|
||||
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 settings
|
||||
const networkSettings = inspectData.NetworkSettings?.Networks || {};
|
||||
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
|
||||
|
||||
// Extract primary network aliases, static IP, and gateway priority
|
||||
let networkAliases: string[] | undefined;
|
||||
let networkIpv4Address: string | undefined;
|
||||
let networkIpv6Address: string | undefined;
|
||||
let macAddress: string | undefined;
|
||||
let networkGwPriority: number | undefined;
|
||||
|
||||
const containerId = inspectData.Id || '';
|
||||
const shortContainerId = containerId.substring(0, 12);
|
||||
|
||||
// Extract compose labels for alias reconstruction
|
||||
const composeProject = config.Labels?.['com.docker.compose.project'];
|
||||
const composeService = config.Labels?.['com.docker.compose.service'];
|
||||
|
||||
for (const [netName, netConfig] of Object.entries(networkSettings)) {
|
||||
const netConf = netConfig as any;
|
||||
const isPrimary = netName === primaryNetwork ||
|
||||
(primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default'));
|
||||
|
||||
if (isPrimary) {
|
||||
// Filter out auto-generated container IDs
|
||||
const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [];
|
||||
networkAliases = allAliases.filter((a: string) =>
|
||||
a !== containerId && a !== shortContainerId
|
||||
);
|
||||
|
||||
// For compose containers, ensure service name and project-service aliases
|
||||
if (composeProject && composeService) {
|
||||
if (!networkAliases) networkAliases = [];
|
||||
if (!networkAliases.includes(composeService)) {
|
||||
networkAliases.push(composeService);
|
||||
}
|
||||
const projectService = `${composeProject}-${composeService}`;
|
||||
if (!networkAliases.includes(projectService)) {
|
||||
networkAliases.push(projectService);
|
||||
}
|
||||
}
|
||||
|
||||
if (!networkAliases || networkAliases.length === 0) {
|
||||
networkAliases = undefined;
|
||||
}
|
||||
|
||||
networkIpv4Address = netConf.IPAMConfig?.IPv4Address || undefined;
|
||||
networkIpv6Address = netConf.IPAMConfig?.IPv6Address || undefined;
|
||||
macAddress = netConf.MacAddress || undefined;
|
||||
networkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0
|
||||
? netConf.GwPriority : undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Device requests (GPU, etc.)
|
||||
const 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;
|
||||
|
||||
return {
|
||||
name,
|
||||
image: config.Image,
|
||||
|
||||
// Command and entrypoint
|
||||
cmd: config.Cmd || undefined,
|
||||
entrypoint: config.Entrypoint || undefined,
|
||||
workingDir: config.WorkingDir || undefined,
|
||||
|
||||
// Environment and labels
|
||||
env: config.Env || [],
|
||||
labels: config.Labels || {},
|
||||
|
||||
// Port mappings
|
||||
ports: Object.keys(ports).length > 0 ? ports : undefined,
|
||||
|
||||
// Volume bindings
|
||||
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
|
||||
|
||||
// Restart policy
|
||||
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
||||
restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount,
|
||||
|
||||
// Network settings
|
||||
networkMode: hostConfig.NetworkMode || undefined,
|
||||
networkAliases,
|
||||
networkIpv4Address,
|
||||
networkIpv6Address,
|
||||
networkGwPriority,
|
||||
|
||||
// User and hostname
|
||||
user: config.User || undefined,
|
||||
hostname: config.Hostname || undefined,
|
||||
domainname: config.Domainname || 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
|
||||
macAddress,
|
||||
|
||||
// Extra hosts
|
||||
extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined,
|
||||
|
||||
// Device requests (GPU)
|
||||
deviceRequests,
|
||||
|
||||
// Container runtime
|
||||
runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined,
|
||||
|
||||
// Read-only root filesystem
|
||||
readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined,
|
||||
|
||||
// CPU pinning
|
||||
cpusetCpus: hostConfig.CpusetCpus || undefined,
|
||||
cpusetMems: hostConfig.CpusetMems || undefined,
|
||||
|
||||
// Additional groups
|
||||
groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined,
|
||||
|
||||
// Memory swappiness
|
||||
memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined,
|
||||
|
||||
// User namespace mode
|
||||
usernsMode: hostConfig.UsernsMode || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a container by recreating it with merged options.
|
||||
* Preserves ALL existing container settings and merges user-provided options on top.
|
||||
*/
|
||||
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
|
||||
const oldContainerInfo = await inspectContainer(id, envId);
|
||||
const wasRunning = oldContainerInfo.State.Running;
|
||||
|
||||
// Extract ALL existing container options
|
||||
const existingOptions = extractContainerOptions(oldContainerInfo);
|
||||
|
||||
// Merge user-provided options on top of existing options
|
||||
// User options take precedence, but we preserve everything not explicitly provided
|
||||
const mergedOptions: CreateContainerOptions = {
|
||||
...existingOptions,
|
||||
...options,
|
||||
// Special handling for labels - merge instead of replace to preserve Docker internal labels
|
||||
labels: {
|
||||
...existingOptions.labels,
|
||||
...options.labels
|
||||
}
|
||||
};
|
||||
|
||||
if (wasRunning) {
|
||||
await stopContainer(id, envId);
|
||||
}
|
||||
|
||||
await removeContainer(id, true, envId);
|
||||
|
||||
const newContainer = await createContainer(options, envId);
|
||||
const newContainer = await createContainer(mergedOptions, envId);
|
||||
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
await newContainer.start();
|
||||
@@ -2746,6 +3049,7 @@ export async function runContainerWithStreaming(options: {
|
||||
binds?: string[];
|
||||
env?: string[];
|
||||
name?: string;
|
||||
user?: string;
|
||||
envId?: number | null;
|
||||
onStdout?: (data: string) => void;
|
||||
onStderr?: (data: string) => void;
|
||||
@@ -2765,6 +3069,11 @@ export async function runContainerWithStreaming(options: {
|
||||
}
|
||||
};
|
||||
|
||||
// Set user if specified (needed for rootless Docker socket access)
|
||||
if (options.user) {
|
||||
containerConfig.User = options.user;
|
||||
}
|
||||
|
||||
const createResult = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
||||
{ method: 'POST', body: JSON.stringify(containerConfig) },
|
||||
|
||||
+151
-15
@@ -7,6 +7,7 @@ import {
|
||||
getGitStack,
|
||||
updateGitStack,
|
||||
upsertStackSource,
|
||||
getEnvironment,
|
||||
type GitRepository,
|
||||
type GitCredential,
|
||||
type GitStackWithRepo
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
|
||||
// Directory for storing cloned repositories
|
||||
const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos';
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos'));
|
||||
|
||||
// Ensure git repos directory exists
|
||||
if (!existsSync(GIT_REPOS_DIR)) {
|
||||
@@ -544,7 +546,21 @@ export function deleteRepositoryFiles(repoId: number): void {
|
||||
|
||||
// === Git Stack Functions ===
|
||||
|
||||
function getStackRepoPath(stackId: number): string {
|
||||
async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise<string> {
|
||||
if (stackName && environmentId) {
|
||||
// Use old path if it already exists (backward compat), otherwise use name-based path
|
||||
const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`);
|
||||
if (existsSync(oldPath)) {
|
||||
return oldPath;
|
||||
}
|
||||
// Format: envName/stackName (e.g. production/webapp) - consistent with internal stacks
|
||||
const env = await getEnvironment(environmentId);
|
||||
const envDir = join(GIT_REPOS_DIR, env ? env.name : String(environmentId));
|
||||
if (!existsSync(envDir)) {
|
||||
mkdirSync(envDir, { recursive: true });
|
||||
}
|
||||
return join(envDir, stackName);
|
||||
}
|
||||
return join(GIT_REPOS_DIR, `stack-${stackId}`);
|
||||
}
|
||||
|
||||
@@ -597,7 +613,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
console.log(`${logPrefix} Repository branch:`, repo.branch);
|
||||
|
||||
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
|
||||
const repoPath = getStackRepoPath(stackId);
|
||||
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
|
||||
const env = await buildGitEnv(credential);
|
||||
|
||||
console.log(`${logPrefix} Local repo path:`, repoPath);
|
||||
@@ -818,14 +834,13 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
};
|
||||
}
|
||||
|
||||
const forceRecreate = syncResult.updated && !!gitStack.envFilePath;
|
||||
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated}, hasEnvFile=${!!gitStack.envFilePath})`);
|
||||
const forceRecreate = syncResult.updated;
|
||||
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
|
||||
|
||||
// Deploy using unified function - handles both new and existing stacks
|
||||
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
|
||||
// Force recreate when git detected changes AND stack has .env file configured
|
||||
// This ensures containers pick up new env var values even if compose file didn't change
|
||||
// Note: Without this, docker compose only detects compose file changes, not env var changes
|
||||
// Force recreate whenever git detected changes to ensure containers pick up
|
||||
// new env var values even if compose file itself didn't change
|
||||
console.log(`${logPrefix} Calling deployStack...`);
|
||||
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
|
||||
console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
|
||||
@@ -835,7 +850,6 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
name: gitStack.stackName,
|
||||
compose: syncResult.composeContent!,
|
||||
envId: gitStack.environmentId,
|
||||
envFileVars: syncResult.envFileVars,
|
||||
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
|
||||
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
|
||||
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||
@@ -923,8 +937,8 @@ export async function testGitStack(stackId: number): Promise<TestResult> {
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteGitStackFiles(stackId: number): void {
|
||||
const repoPath = getStackRepoPath(stackId);
|
||||
export async function deleteGitStackFiles(stackId: number, stackName?: string, environmentId?: number | null): Promise<void> {
|
||||
const repoPath = await getStackRepoPath(stackId, stackName, environmentId);
|
||||
try {
|
||||
if (existsSync(repoPath)) {
|
||||
rmSync(repoPath, { recursive: true, force: true });
|
||||
@@ -967,7 +981,7 @@ export async function deployGitStackWithProgress(
|
||||
}
|
||||
|
||||
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
|
||||
const repoPath = getStackRepoPath(stackId);
|
||||
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
|
||||
const env = await buildGitEnv(credential);
|
||||
|
||||
const totalSteps = 5;
|
||||
@@ -1079,7 +1093,6 @@ export async function deployGitStackWithProgress(
|
||||
name: gitStack.stackName,
|
||||
compose: composeContent,
|
||||
envId: gitStack.environmentId,
|
||||
envFileVars,
|
||||
sourceDir: composeDir // Copy entire directory from git repo
|
||||
});
|
||||
|
||||
@@ -1124,7 +1137,7 @@ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: st
|
||||
return { files: [], error: 'Git stack not found' };
|
||||
}
|
||||
|
||||
const repoPath = getStackRepoPath(stackId);
|
||||
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
|
||||
if (!existsSync(repoPath)) {
|
||||
return { files: [], error: 'Repository not synced - deploy the stack first' };
|
||||
}
|
||||
@@ -1237,7 +1250,7 @@ export async function readGitStackEnvFile(
|
||||
return { vars: {}, error: 'Git stack not found' };
|
||||
}
|
||||
|
||||
const repoPath = getStackRepoPath(stackId);
|
||||
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
|
||||
if (!existsSync(repoPath)) {
|
||||
return { vars: {}, error: 'Repository not synced - deploy the stack first' };
|
||||
}
|
||||
@@ -1262,3 +1275,126 @@ export async function readGitStackEnvFile(
|
||||
return { vars: {}, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
interface PreviewEnvOptions {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
credential: {
|
||||
id: number;
|
||||
authType: string;
|
||||
sshPrivateKey?: string | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
} | null;
|
||||
composePath: string;
|
||||
envFilePath: string | null;
|
||||
}
|
||||
|
||||
interface PreviewEnvResult {
|
||||
vars: Record<string, string>;
|
||||
sources: Record<string, '.env' | 'envFile'>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a repository to a temp directory and read env files for preview.
|
||||
* Used to populate env editor when creating a new git stack.
|
||||
* Cleans up temp directory after reading.
|
||||
*/
|
||||
export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<PreviewEnvResult> {
|
||||
const { repoUrl, branch, credential, composePath, envFilePath } = options;
|
||||
const logPrefix = '[Git:Preview]';
|
||||
|
||||
// Create a unique temp directory
|
||||
const tempId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
const tempDir = join(GIT_REPOS_DIR, tempId);
|
||||
|
||||
console.log(`${logPrefix} Starting preview for ${repoUrl}`);
|
||||
console.log(`${logPrefix} Temp directory: ${tempDir}`);
|
||||
|
||||
try {
|
||||
// Ensure temp directory exists
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Build git environment with credentials
|
||||
// Cast credential to GitCredential type (only uses id, authType, sshPrivateKey)
|
||||
const env = await buildGitEnv(credential as GitCredential | null);
|
||||
const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);
|
||||
|
||||
// Clone with depth 1 (shallow clone for speed)
|
||||
const cloneProc = Bun.spawn(
|
||||
['git', 'clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
|
||||
{
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env
|
||||
}
|
||||
);
|
||||
|
||||
const cloneStderr = await new Response(cloneProc.stderr).text();
|
||||
const cloneExitCode = await cloneProc.exited;
|
||||
|
||||
if (cloneExitCode !== 0) {
|
||||
console.error(`${logPrefix} Clone failed:`, cloneStderr);
|
||||
return { vars: {}, sources: {}, error: `Failed to clone repository: ${cloneStderr.trim()}` };
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Clone successful`);
|
||||
|
||||
// Determine the compose directory (where .env file should be)
|
||||
const composeDir = dirname(composePath);
|
||||
const baseEnvPath = join(tempDir, composeDir, '.env');
|
||||
|
||||
const vars: Record<string, string> = {};
|
||||
const sources: Record<string, '.env' | 'envFile'> = {};
|
||||
|
||||
// Read base .env file if it exists
|
||||
if (existsSync(baseEnvPath)) {
|
||||
console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`);
|
||||
const content = await Bun.file(baseEnvPath).text();
|
||||
const baseVars = parseEnvFileContent(content, 'preview');
|
||||
for (const [key, value] of Object.entries(baseVars)) {
|
||||
vars[key] = value;
|
||||
sources[key] = '.env';
|
||||
}
|
||||
console.log(`${logPrefix} Found ${Object.keys(baseVars).length} vars in .env`);
|
||||
} else {
|
||||
console.log(`${logPrefix} No .env file at ${baseEnvPath}`);
|
||||
}
|
||||
|
||||
// Read additional env file if specified
|
||||
if (envFilePath) {
|
||||
const additionalEnvPath = join(tempDir, envFilePath);
|
||||
if (existsSync(additionalEnvPath)) {
|
||||
console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`);
|
||||
const content = await Bun.file(additionalEnvPath).text();
|
||||
const additionalVars = parseEnvFileContent(content, 'preview');
|
||||
for (const [key, value] of Object.entries(additionalVars)) {
|
||||
vars[key] = value;
|
||||
sources[key] = 'envFile';
|
||||
}
|
||||
console.log(`${logPrefix} Found ${Object.keys(additionalVars).length} vars in ${envFilePath}`);
|
||||
} else {
|
||||
console.log(`${logPrefix} Additional env file not found: ${additionalEnvPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Total variables: ${Object.keys(vars).length}`);
|
||||
|
||||
return { vars, sources };
|
||||
} catch (error: any) {
|
||||
console.error(`${logPrefix} Error:`, error);
|
||||
return { vars: {}, sources: {}, error: error.message };
|
||||
} finally {
|
||||
// Always clean up temp directory
|
||||
cleanupSshKey(credential as GitCredential | null);
|
||||
try {
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log(`${logPrefix} Cleaned up temp directory`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error(`${logPrefix} Failed to cleanup temp directory:`, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,72 @@ export function translateContainerPathViaMount(containerPath: string): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host path for the Docker socket mount.
|
||||
* This is needed for sibling containers (e.g., scanners) that need socket access.
|
||||
*
|
||||
* When Dockhand runs in Docker with a non-standard socket mount like:
|
||||
* -v /var/run/user/1000/docker.sock:/var/run/docker.sock
|
||||
*
|
||||
* We need to detect the HOST path (/var/run/user/1000/docker.sock) so that
|
||||
* scanner containers can bind-mount the correct path.
|
||||
*
|
||||
* @returns The host path to Docker socket, or '/var/run/docker.sock' as default
|
||||
*/
|
||||
export function getHostDockerSocket(): string {
|
||||
// Priority 1: Explicit environment variable override
|
||||
if (process.env.HOST_DOCKER_SOCKET) {
|
||||
console.log(`[HostPath] Using HOST_DOCKER_SOCKET from env: ${process.env.HOST_DOCKER_SOCKET}`);
|
||||
return process.env.HOST_DOCKER_SOCKET;
|
||||
}
|
||||
|
||||
// Priority 2: Look up from cached mounts (populated by detectHostDataDir on startup)
|
||||
if (cachedMounts && cachedMounts.length > 0) {
|
||||
console.log(`[HostPath] Searching ${cachedMounts.length} cached mount(s) for Docker socket`);
|
||||
|
||||
// Find mount where destination is docker.sock
|
||||
const socketMount = cachedMounts.find(m =>
|
||||
m.destination === '/var/run/docker.sock' ||
|
||||
m.destination === '/run/docker.sock' ||
|
||||
m.destination.endsWith('/docker.sock')
|
||||
);
|
||||
|
||||
if (socketMount) {
|
||||
console.log(`[HostPath] Found Docker socket mount: ${socketMount.source} -> ${socketMount.destination}`);
|
||||
return socketMount.source;
|
||||
}
|
||||
|
||||
// Log available mounts for debugging
|
||||
console.log(`[HostPath] No Docker socket mount found. Available mounts:`);
|
||||
for (const m of cachedMounts) {
|
||||
console.log(`[HostPath] ${m.source} -> ${m.destination}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[HostPath] No cached mounts available (not running in Docker or detectHostDataDir not called)`);
|
||||
}
|
||||
|
||||
// Priority 3: Default fallback (works for standard Docker setups)
|
||||
console.log(`[HostPath] Using default Docker socket: /var/run/docker.sock`);
|
||||
return '/var/run/docker.sock';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UID from a user-specific Docker socket path.
|
||||
* User-specific sockets are at /run/user/<uid>/docker.sock
|
||||
*
|
||||
* @param socketPath - The host Docker socket path
|
||||
* @returns The UID as a string (e.g., "1000"), or null if not a user-specific path
|
||||
*/
|
||||
export function extractUidFromSocketPath(socketPath: string): string | null {
|
||||
// Match patterns like /run/user/1000/docker.sock or /var/run/user/1000/docker.sock
|
||||
const match = socketPath.match(/\/user\/(\d+)\/docker\.sock$/);
|
||||
if (match) {
|
||||
console.log(`[HostPath] Extracted UID ${match[1]} from socket path: ${socketPath}`);
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative volume paths in a compose file to use absolute host paths.
|
||||
* This is necessary when Dockhand runs inside Docker with a mounted data volume.
|
||||
|
||||
+194
-120
@@ -29,8 +29,14 @@ export interface NotificationPayload {
|
||||
environmentName?: string;
|
||||
}
|
||||
|
||||
// Result type for functions that can return detailed errors
|
||||
export interface NotificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Send notification via SMTP
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
@@ -67,41 +73,43 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay
|
||||
html
|
||||
});
|
||||
|
||||
return true;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Notifications] SMTP send failed:', errorMsg);
|
||||
return false;
|
||||
return { success: false, error: `SMTP error: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Apprise URL and send notification
|
||||
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<boolean> {
|
||||
let success = true;
|
||||
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of config.urls) {
|
||||
try {
|
||||
const sent = await sendToAppriseUrl(url, payload);
|
||||
if (!sent) success = false;
|
||||
const result = await sendToAppriseUrl(url, payload);
|
||||
if (!result.success && result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to ${url}:`, errorMsg);
|
||||
success = false;
|
||||
errors.push(`Failed to send: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Send to a single Apprise URL
|
||||
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
// Extract protocol from Apprise URL format (protocol://...)
|
||||
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
|
||||
const protocolMatch = url.match(/^([a-z]+):\/\//i);
|
||||
if (!protocolMatch) {
|
||||
console.error('[Notifications] Invalid Apprise URL format - missing protocol:', url);
|
||||
return false;
|
||||
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
|
||||
}
|
||||
const protocol = protocolMatch[1].toLowerCase();
|
||||
|
||||
@@ -127,42 +135,48 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
|
||||
case 'jsons':
|
||||
return await sendGenericWebhook(url, payload);
|
||||
default:
|
||||
console.warn(`[Notifications] Unsupported Apprise protocol: ${protocol}`);
|
||||
return false;
|
||||
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Notifications] Failed to parse Apprise URL:', errorMsg);
|
||||
return false;
|
||||
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Discord webhook
|
||||
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// discord://webhook_id/webhook_token or discords://...
|
||||
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: titleWithEnv,
|
||||
description: payload.message,
|
||||
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
||||
...(payload.environmentName && {
|
||||
footer: { text: `Environment: ${payload.environmentName}` }
|
||||
})
|
||||
}]
|
||||
})
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: titleWithEnv,
|
||||
description: payload.message,
|
||||
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
||||
...(payload.environmentName && {
|
||||
footer: { text: `Environment: ${payload.environmentName}` }
|
||||
})
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Slack webhook
|
||||
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// slack://token_a/token_b/token_c or webhook URL
|
||||
let url: string;
|
||||
if (appriseUrl.includes('hooks.slack.com')) {
|
||||
@@ -173,24 +187,32 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom
|
||||
}
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
})
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram
|
||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// tgram://bot_token/chat_id
|
||||
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
console.error('[Notifications] Invalid Telegram URL format. Expected: tgram://bot_token/chat_id');
|
||||
return false;
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' };
|
||||
}
|
||||
|
||||
const [, botToken, chatId] = match;
|
||||
@@ -213,110 +235,162 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('[Notifications] Telegram API error:', response.status, errorData);
|
||||
const errorData = await response.json().catch(() => ({})) as { description?: string };
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
|
||||
return response.ok;
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Notifications] Telegram send failed:', errorMsg);
|
||||
return false;
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// gotify://hostname/token or gotifys://hostname/token
|
||||
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) return false;
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const [, hostname, token] = match;
|
||||
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
|
||||
const url = `${protocol}://${hostname}/message?token=${token}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
|
||||
})
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
|
||||
})
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ntfy
|
||||
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
// ntfy://topic or ntfys://hostname/topic
|
||||
let url: string;
|
||||
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// ntfy://topic (public ntfy.sh)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with auth)
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
|
||||
if (path.includes('/')) {
|
||||
// Custom server
|
||||
let url: string;
|
||||
let auth: string | null = null;
|
||||
|
||||
// Check for user:pass@host/topic format
|
||||
const authMatch = path.match(/^([^:]+):([^@]+)@(.+)$/);
|
||||
if (authMatch) {
|
||||
const [, user, pass, hostAndTopic] = authMatch;
|
||||
auth = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else if (path.includes('/')) {
|
||||
// Custom server without auth
|
||||
url = `${isSecure ? 'https' : 'http'}://${path}`;
|
||||
} else {
|
||||
// Default ntfy.sh
|
||||
url = `https://ntfy.sh/${path}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Title': payload.title,
|
||||
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
|
||||
'Tags': payload.type || 'info'
|
||||
},
|
||||
body: payload.message
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
'Title': payload.title,
|
||||
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
|
||||
'Tags': payload.type || 'info'
|
||||
};
|
||||
|
||||
return response.ok;
|
||||
if (auth) {
|
||||
headers['Authorization'] = `Basic ${auth}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.message
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Pushover
|
||||
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// pushover://user_key/api_token
|
||||
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) return false;
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
|
||||
}
|
||||
|
||||
const [, userKey, apiToken] = match;
|
||||
const url = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Generic JSON webhook
|
||||
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// json://hostname/path or jsons://hostname/path
|
||||
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification to all enabled channels
|
||||
@@ -325,15 +399,15 @@ export async function sendNotification(payload: NotificationPayload): Promise<{
|
||||
const results: { name: string; success: boolean }[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
let success = false;
|
||||
let result: NotificationResult = { success: false };
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
success = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
success = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
results.push({ name: setting.name, success });
|
||||
results.push({ name: setting.name, success: result.success });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -343,7 +417,7 @@ export async function sendNotification(payload: NotificationPayload): Promise<{
|
||||
}
|
||||
|
||||
// Test a specific notification setting
|
||||
export async function testNotification(setting: NotificationSettingData): Promise<boolean> {
|
||||
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
|
||||
const payload: NotificationPayload = {
|
||||
title: 'Dockhand Test Notification',
|
||||
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
|
||||
@@ -356,7 +430,7 @@ export async function testNotification(setting: NotificationSettingData): Promis
|
||||
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
return false;
|
||||
return { success: false, error: 'Unknown notification type' };
|
||||
}
|
||||
|
||||
// Map Docker action to notification event type
|
||||
@@ -432,13 +506,13 @@ export async function sendEnvironmentNotification(
|
||||
|
||||
for (const notif of envNotifications) {
|
||||
try {
|
||||
let success = false;
|
||||
let result: NotificationResult = { success: false };
|
||||
if (notif.channelType === 'smtp') {
|
||||
success = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
||||
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
||||
} else if (notif.channelType === 'apprise') {
|
||||
success = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
||||
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (success) sent++;
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
@@ -505,13 +579,13 @@ export async function sendEventNotification(
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
let success = false;
|
||||
let result: NotificationResult = { success: false };
|
||||
if (channel.channel_type === 'smtp') {
|
||||
success = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
||||
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
||||
} else if (channel.channel_type === 'apprise') {
|
||||
success = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
||||
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (success) sent++;
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
+110
-6
@@ -14,6 +14,9 @@ import {
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath } from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown } from 'node:fs/promises';
|
||||
|
||||
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
|
||||
|
||||
@@ -66,6 +69,10 @@ export async function sendVulnerabilityNotifications(
|
||||
const GRYPE_VOLUME_NAME = 'dockhand-grype-db';
|
||||
const TRIVY_VOLUME_NAME = 'dockhand-trivy-db';
|
||||
|
||||
// Scanner cache directory for rootless Docker (bind mounts instead of volumes)
|
||||
const DATA_DIR = process.env.DATA_DIR || '/app/data';
|
||||
const SCANNER_CACHE_DIR = 'scanner-cache';
|
||||
|
||||
// Track running scanner instances to detect concurrent scans
|
||||
const runningScanners = new Map<string, number>(); // key: "grype" or "trivy", value: count
|
||||
|
||||
@@ -381,6 +388,43 @@ async function ensureVolume(volumeName: string, envId?: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure scanner cache directory exists with correct ownership for rootless Docker.
|
||||
* Creates the directory in Dockhand's data volume and chowns it to the target UID.
|
||||
*
|
||||
* This is needed because Docker volumes are always created with root ownership,
|
||||
* but rootless Docker scanners run as a non-root user (e.g., UID 1000).
|
||||
* By using a bind mount from Dockhand's data directory (which Dockhand can chown
|
||||
* since it runs as root), the scanner can write to its cache.
|
||||
*
|
||||
* @param scannerType - 'grype' or 'trivy'
|
||||
* @param uid - Target UID for ownership (e.g., '1000')
|
||||
* @returns The HOST path to the cache directory (for bind mounting into scanner)
|
||||
*/
|
||||
async function ensureScannerCacheDir(
|
||||
scannerType: 'grype' | 'trivy',
|
||||
uid: string
|
||||
): Promise<string> {
|
||||
const containerPath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType);
|
||||
|
||||
// Create directory if needed (recursive)
|
||||
await mkdir(containerPath, { recursive: true });
|
||||
|
||||
// Chown to the target UID so scanner can write
|
||||
const uidNum = parseInt(uid, 10);
|
||||
await chown(containerPath, uidNum, uidNum);
|
||||
console.log(`[Scanner] Set ownership of ${containerPath} to ${uid}:${uid}`);
|
||||
|
||||
// Return the HOST path for bind mounting
|
||||
const hostDataDir = getHostDataDir();
|
||||
if (hostDataDir) {
|
||||
return `${hostDataDir}/${SCANNER_CACHE_DIR}/${scannerType}`;
|
||||
}
|
||||
|
||||
// Fallback: not running in Docker, use container path as-is
|
||||
return containerPath;
|
||||
}
|
||||
|
||||
// Run scanner in a fresh container with volume-cached database
|
||||
async function runScannerContainer(
|
||||
scannerImage: string,
|
||||
@@ -390,9 +434,7 @@ async function runScannerContainer(
|
||||
envId?: number,
|
||||
onOutput?: (line: string) => void
|
||||
): Promise<string> {
|
||||
// Ensure database cache volume exists
|
||||
const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME;
|
||||
await ensureVolume(volumeName, envId);
|
||||
console.log(`[Scanner] Starting ${scannerType} scan for image: ${imageName}, envId: ${envId ?? 'local'}`);
|
||||
|
||||
// Check if another scanner of the same type is already running
|
||||
// If so, use a unique cache subdirectory to avoid lock conflicts
|
||||
@@ -407,11 +449,59 @@ async function runScannerContainer(
|
||||
const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy';
|
||||
const dbPath = scanId ? `${basePath}${scanId}` : basePath;
|
||||
|
||||
// Detect the host Docker socket path based on connection type
|
||||
// For local socket environments, detect the actual host socket path (handles rootless Docker)
|
||||
// For remote environments (hawser/direct), scanner runs remotely and uses standard path
|
||||
const env = envId ? await getEnvironment(envId) : undefined;
|
||||
const connectionType = env?.connectionType;
|
||||
|
||||
let hostSocketPath: string;
|
||||
let containerUser: string | undefined;
|
||||
|
||||
if (!connectionType || connectionType === 'socket') {
|
||||
// Local socket environment - detect host socket path (handles rootless Docker)
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
console.log(`[Scanner] Local socket scan - detected host Docker socket: ${hostSocketPath}`);
|
||||
|
||||
// For user-specific Docker sockets, run scanner as that user
|
||||
// e.g., /run/user/1000/docker.sock -> run as UID 1000
|
||||
const uid = extractUidFromSocketPath(hostSocketPath);
|
||||
if (uid) {
|
||||
containerUser = uid;
|
||||
console.log(`[Scanner] Rootless Docker detected (UID ${containerUser})`);
|
||||
}
|
||||
} else {
|
||||
// Remote environment (direct/hawser-standard/hawser-edge)
|
||||
// Scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan (${connectionType}) - using standard socket path: ${hostSocketPath}`);
|
||||
}
|
||||
|
||||
// Determine cache storage strategy based on environment
|
||||
// For rootless Docker: use bind mount from data directory with correct ownership
|
||||
// For standard Docker: use named volume (root-owned is fine when running as root)
|
||||
let cacheBind: string;
|
||||
const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME;
|
||||
|
||||
if (containerUser) {
|
||||
// Rootless Docker: use bind mount from data directory with correct ownership
|
||||
const hostCachePath = await ensureScannerCacheDir(scannerType, containerUser);
|
||||
cacheBind = `${hostCachePath}:${basePath}`;
|
||||
console.log(`[Scanner] Rootless mode - using bind mount: ${cacheBind}`);
|
||||
} else {
|
||||
// Standard Docker: use named volume (root-owned is fine when running as root)
|
||||
await ensureVolume(volumeName, envId);
|
||||
cacheBind = `${volumeName}:${basePath}`;
|
||||
console.log(`[Scanner] Standard mode - using volume: ${volumeName}`);
|
||||
}
|
||||
|
||||
const binds = [
|
||||
'/var/run/docker.sock:/var/run/docker.sock:ro',
|
||||
`${volumeName}:${basePath}` // Always mount to base path
|
||||
`${hostSocketPath}:/var/run/docker.sock:ro`,
|
||||
cacheBind
|
||||
];
|
||||
|
||||
console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`);
|
||||
|
||||
// Environment variables to ensure scanners use the correct cache path
|
||||
// For concurrent scans, use a unique subdirectory
|
||||
const envVars = scannerType === 'grype'
|
||||
@@ -421,7 +511,11 @@ async function runScannerContainer(
|
||||
if (scanId) {
|
||||
console.log(`[Scanner] Concurrent scan detected - using unique cache dir: ${dbPath}`);
|
||||
}
|
||||
console.log(`[Scanner] Running ${scannerType} with volume ${volumeName} mounted at ${basePath}`);
|
||||
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
|
||||
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
|
||||
if (containerUser) {
|
||||
console.log(`[Scanner] Running scanner container as UID ${containerUser} to match socket owner`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the scanner container
|
||||
@@ -431,6 +525,7 @@ async function runScannerContainer(
|
||||
binds,
|
||||
env: envVars,
|
||||
name: `dockhand-${scannerType}-${Date.now()}`,
|
||||
user: containerUser,
|
||||
envId,
|
||||
onStderr: (data) => {
|
||||
// Stream stderr lines for real-time progress output
|
||||
@@ -443,6 +538,15 @@ async function runScannerContainer(
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
||||
if (output.length === 0) {
|
||||
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
|
||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`);
|
||||
console.error(`[Scanner] Host socket path used: ${hostSocketPath}`);
|
||||
} else if (output.length < 100) {
|
||||
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
} finally {
|
||||
// Decrement running counter
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
getEnvironments,
|
||||
getEnvUpdateCheckSettings,
|
||||
getAllEnvUpdateCheckSettings,
|
||||
getImagePruneSettings,
|
||||
getAllImagePruneSettings,
|
||||
getEnvironment,
|
||||
getEnvironmentTimezone,
|
||||
getDefaultTimezone
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
import { runContainerUpdate } from './tasks/container-update';
|
||||
import { runGitStackSync } from './tasks/git-stack-sync';
|
||||
import { runEnvUpdateCheckJob } from './tasks/env-update-check';
|
||||
import { runImagePrune } from './tasks/image-prune';
|
||||
import {
|
||||
runScheduleCleanupJob,
|
||||
runEventCleanupJob,
|
||||
@@ -247,7 +250,26 @@ export async function refreshAllSchedules(): Promise<void> {
|
||||
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`);
|
||||
// Register image prune schedules
|
||||
let imagePruneCount = 0;
|
||||
try {
|
||||
const pruneConfigs = await getAllImagePruneSettings();
|
||||
for (const { envId, settings } of pruneConfigs) {
|
||||
if (settings.enabled && settings.cronExpression) {
|
||||
const registered = await registerSchedule(
|
||||
envId,
|
||||
'image_prune',
|
||||
envId
|
||||
);
|
||||
if (registered) imagePruneCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Scheduler] Error loading image prune schedules:', errorMsg);
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules, ${imagePruneCount} image prune schedules`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +278,7 @@ export async function refreshAllSchedules(): Promise<void> {
|
||||
*/
|
||||
export async function registerSchedule(
|
||||
scheduleId: number,
|
||||
type: 'container_update' | 'git_stack_sync' | 'env_update_check',
|
||||
type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune',
|
||||
environmentId: number | null
|
||||
): Promise<boolean> {
|
||||
const key = `${type}-${scheduleId}`;
|
||||
@@ -290,6 +312,14 @@ export async function registerSchedule(
|
||||
cronExpression = config.cron;
|
||||
entityName = `Update: ${env.name}`;
|
||||
enabled = config.enabled;
|
||||
} else if (type === 'image_prune') {
|
||||
const config = await getImagePruneSettings(scheduleId);
|
||||
if (!config) return false;
|
||||
const env = await getEnvironment(scheduleId);
|
||||
if (!env) return false;
|
||||
cronExpression = config.cronExpression;
|
||||
entityName = `Prune: ${env.name}`;
|
||||
enabled = config.enabled;
|
||||
}
|
||||
|
||||
// Don't create job if disabled or no cron expression
|
||||
@@ -315,6 +345,10 @@ export async function registerSchedule(
|
||||
const config = await getEnvUpdateCheckSettings(scheduleId);
|
||||
if (!config || !config.enabled) return;
|
||||
await runEnvUpdateCheckJob(scheduleId, 'cron');
|
||||
} else if (type === 'image_prune') {
|
||||
const config = await getImagePruneSettings(scheduleId);
|
||||
if (!config || !config.enabled) return;
|
||||
await runImagePrune(scheduleId, 'cron');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -334,7 +368,7 @@ export async function registerSchedule(
|
||||
*/
|
||||
export function unregisterSchedule(
|
||||
scheduleId: number,
|
||||
type: 'container_update' | 'git_stack_sync' | 'env_update_check'
|
||||
type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune'
|
||||
): void {
|
||||
const key = `${type}-${scheduleId}`;
|
||||
const job = activeJobs.get(key);
|
||||
@@ -407,6 +441,22 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro
|
||||
console.error('[Scheduler] Error refreshing env update check schedule:', errorMsg);
|
||||
}
|
||||
|
||||
// Re-register image prune schedule for this environment
|
||||
try {
|
||||
const config = await getImagePruneSettings(environmentId);
|
||||
if (config && config.enabled && config.cronExpression) {
|
||||
const registered = await registerSchedule(
|
||||
environmentId,
|
||||
'image_prune',
|
||||
environmentId
|
||||
);
|
||||
if (registered) refreshedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Scheduler] Error refreshing image prune schedule:', errorMsg);
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`);
|
||||
}
|
||||
|
||||
@@ -546,6 +596,30 @@ export async function triggerEnvUpdateCheck(environmentId: number): Promise<{ su
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger an image prune for an environment.
|
||||
*/
|
||||
export async function triggerImagePrune(environmentId: number): Promise<{ success: boolean; executionId?: number; error?: string }> {
|
||||
try {
|
||||
const config = await getImagePruneSettings(environmentId);
|
||||
if (!config) {
|
||||
return { success: false, error: 'Image prune settings not found for this environment' };
|
||||
}
|
||||
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) {
|
||||
return { success: false, error: 'Environment not found' };
|
||||
}
|
||||
|
||||
// Run in background
|
||||
runImagePrune(environmentId, 'manual');
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a system job (schedule cleanup, event cleanup, etc.).
|
||||
*/
|
||||
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
getImageIdByTag,
|
||||
removeTempImage,
|
||||
tagImage,
|
||||
connectContainerToNetwork
|
||||
connectContainerToNetwork,
|
||||
extractContainerOptions
|
||||
} from '../../docker';
|
||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
@@ -589,8 +590,8 @@ export async function recreateContainer(
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(container.id, envId) as any;
|
||||
const wasRunning = inspectData.State.Running;
|
||||
const config = inspectData.Config;
|
||||
const hostConfig = inspectData.HostConfig;
|
||||
const config = inspectData.Config;
|
||||
|
||||
log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`);
|
||||
log?.(`Preserving all container settings...`);
|
||||
@@ -605,96 +606,19 @@ export async function recreateContainer(
|
||||
log?.('Removing old container...');
|
||||
await removeContainer(container.id, true, envId);
|
||||
|
||||
// =============================================================================
|
||||
// Extract ALL settings from the original container
|
||||
// =============================================================================
|
||||
// Extract ALL settings using the shared helper function
|
||||
const containerOptions = extractContainerOptions(inspectData);
|
||||
|
||||
// Port bindings - preserve all host port mappings including HostIp
|
||||
const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {};
|
||||
if (hostConfig.PortBindings) {
|
||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
||||
if (bindings && (bindings as any[]).length > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Volume bindings - preserve ALL volumes including anonymous volumes
|
||||
// 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
|
||||
// Extract additional networks for reconnection (not handled by extractContainerOptions)
|
||||
// The helper extracts primary network settings, but we need to handle secondary networks separately
|
||||
const networkSettings = inspectData.NetworkSettings?.Networks || {};
|
||||
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
|
||||
const shortContainerId = container.id.substring(0, 12);
|
||||
|
||||
// Extract compose labels for alias reconstruction
|
||||
const composeProject = config.Labels?.['com.docker.compose.project'];
|
||||
const composeService = config.Labels?.['com.docker.compose.service'];
|
||||
|
||||
// Build network info for reconnection (including aliases, IPs, and gateway priority)
|
||||
interface NetworkInfo {
|
||||
name: string;
|
||||
aliases: string[];
|
||||
@@ -703,68 +627,46 @@ export async function recreateContainer(
|
||||
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;
|
||||
// Log primary network info
|
||||
if (containerOptions.networkAliases?.length) {
|
||||
log?.(`Primary network aliases: ${containerOptions.networkAliases.join(', ')}`);
|
||||
}
|
||||
|
||||
// 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 (containerOptions.networkIpv4Address) {
|
||||
log?.(`Primary network static IPv4: ${containerOptions.networkIpv4Address}`);
|
||||
}
|
||||
if (primaryNetworkIpv4) {
|
||||
log?.(`Primary network static IPv4: ${primaryNetworkIpv4}`);
|
||||
if (containerOptions.macAddress) {
|
||||
log?.(`Primary network MAC address: ${containerOptions.macAddress}`);
|
||||
}
|
||||
if (primaryNetworkMacAddress) {
|
||||
log?.(`Primary network MAC address: ${primaryNetworkMacAddress}`);
|
||||
}
|
||||
if (primaryNetworkGwPriority !== undefined) {
|
||||
log?.(`Primary network gateway priority: ${primaryNetworkGwPriority}`);
|
||||
if (containerOptions.networkGwPriority !== undefined) {
|
||||
log?.(`Primary network gateway priority: ${containerOptions.networkGwPriority}`);
|
||||
}
|
||||
} else {
|
||||
// Secondary network - add to reconnection list
|
||||
// Use DNSNames as fallback for aliases (see comment above for primary network)
|
||||
const secondaryAliases = ((netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [])
|
||||
.filter((a: string) => a !== container.id && a !== shortContainerId);
|
||||
|
||||
// For compose containers, ensure service name and project-service aliases on secondary networks
|
||||
if (composeProject && composeService) {
|
||||
if (!secondaryAliases.includes(composeService)) {
|
||||
secondaryAliases.push(composeService);
|
||||
}
|
||||
const projectService = `${composeProject}-${composeService}`;
|
||||
if (!secondaryAliases.includes(projectService)) {
|
||||
secondaryAliases.push(projectService);
|
||||
}
|
||||
}
|
||||
|
||||
additionalNetworks.push({
|
||||
name: netName,
|
||||
aliases: (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [],
|
||||
aliases: secondaryAliases,
|
||||
ipv4Address: netConf.IPAMConfig?.IPv4Address || undefined,
|
||||
ipv6Address: netConf.IPAMConfig?.IPv6Address || undefined,
|
||||
gwPriority: netConf.GwPriority !== undefined && netConf.GwPriority !== 0
|
||||
@@ -778,165 +680,21 @@ export async function recreateContainer(
|
||||
}
|
||||
|
||||
// Log extra hosts if present
|
||||
if (hostConfig.ExtraHosts?.length > 0) {
|
||||
log?.(`Extra hosts: ${hostConfig.ExtraHosts.join(', ')}`);
|
||||
if (containerOptions.extraHosts?.length) {
|
||||
log?.(`Extra hosts: ${containerOptions.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}]`);
|
||||
if (containerOptions.deviceRequests?.length) {
|
||||
for (const dr of containerOptions.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({
|
||||
name: containerName,
|
||||
image: config.Image,
|
||||
|
||||
// Command and entrypoint
|
||||
cmd: config.Cmd || undefined,
|
||||
entrypoint: config.Entrypoint || undefined,
|
||||
workingDir: config.WorkingDir || undefined,
|
||||
|
||||
// Environment and labels
|
||||
env: config.Env || [],
|
||||
labels: config.Labels || {},
|
||||
|
||||
// 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',
|
||||
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);
|
||||
const newContainer = await createContainer(containerOptions, envId);
|
||||
|
||||
// Reconnect to additional networks with aliases, static IPs, and gateway priority (before starting)
|
||||
if (additionalNetworks.length > 0) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Image Prune Task
|
||||
*
|
||||
* Handles scheduled pruning of unused Docker images per environment.
|
||||
*/
|
||||
|
||||
import type { ScheduleTrigger, ImagePruneSettings } from '../../db';
|
||||
import {
|
||||
getImagePruneSettings,
|
||||
setImagePruneSettings,
|
||||
getEnvironment,
|
||||
createScheduleExecution,
|
||||
updateScheduleExecution,
|
||||
appendScheduleExecutionLog
|
||||
} from '../../db';
|
||||
import { pruneImages } from '../../docker';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
|
||||
/**
|
||||
* System job ID for image prune (starts at 100 to avoid conflicts with other system jobs)
|
||||
*/
|
||||
export const SYSTEM_IMAGE_PRUNE_BASE_ID = 100;
|
||||
|
||||
/**
|
||||
* Execute image prune for an environment.
|
||||
*/
|
||||
export async function runImagePrune(
|
||||
envId: number,
|
||||
triggeredBy: ScheduleTrigger
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Get environment info for logging
|
||||
const env = await getEnvironment(envId);
|
||||
if (!env) {
|
||||
console.error(`[Image Prune] Environment ${envId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get prune settings
|
||||
const settings = await getImagePruneSettings(envId);
|
||||
if (!settings) {
|
||||
console.error(`[Image Prune] No settings found for environment ${envId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create execution record
|
||||
const execution = await createScheduleExecution({
|
||||
scheduleType: 'image_prune',
|
||||
scheduleId: envId,
|
||||
environmentId: envId,
|
||||
entityName: `Image prune: ${env.name}`,
|
||||
triggeredBy,
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
await updateScheduleExecution(execution.id, {
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const log = async (message: string) => {
|
||||
console.log(`[Image Prune] [${env.name}] ${message}`);
|
||||
await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const pruneMode = settings.pruneMode || 'dangling';
|
||||
const dangling = pruneMode === 'dangling';
|
||||
|
||||
await log(`Starting image prune (mode: ${pruneMode})`);
|
||||
|
||||
// Execute prune
|
||||
const result = await pruneImages(dangling, envId);
|
||||
|
||||
// Extract space reclaimed and images removed from result
|
||||
const spaceReclaimed = result?.SpaceReclaimed || 0;
|
||||
const imagesRemoved = result?.ImagesDeleted?.length || 0;
|
||||
|
||||
// Format space for human-readable output
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
await log(`Prune completed: ${imagesRemoved} images removed, ${formatBytes(spaceReclaimed)} reclaimed`);
|
||||
|
||||
// Update settings with last prune info
|
||||
const updatedSettings: ImagePruneSettings = {
|
||||
...settings,
|
||||
lastPruned: new Date().toISOString(),
|
||||
lastResult: {
|
||||
spaceReclaimed,
|
||||
imagesRemoved
|
||||
}
|
||||
};
|
||||
await setImagePruneSettings(envId, updatedSettings);
|
||||
|
||||
// Update execution record
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'success',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: {
|
||||
pruneMode,
|
||||
spaceReclaimed,
|
||||
imagesRemoved,
|
||||
deletedImages: result?.ImagesDeleted?.map((img: any) => img.Deleted || img.Untagged).filter(Boolean)
|
||||
}
|
||||
});
|
||||
|
||||
// Send success notification
|
||||
await sendEventNotification('image_prune_success', {
|
||||
title: 'Image prune completed',
|
||||
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
|
||||
type: 'success'
|
||||
}, envId);
|
||||
|
||||
} catch (error: any) {
|
||||
await log(`Error: ${error.message}`);
|
||||
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'failed',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
errorMessage: error.message
|
||||
});
|
||||
|
||||
// Send failure notification
|
||||
await sendEventNotification('image_prune_failed', {
|
||||
title: 'Image prune failed',
|
||||
message: `Failed to prune images: ${error.message}`,
|
||||
type: 'error'
|
||||
}, envId);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join, basename, dirname, resolve } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
|
||||
|
||||
// Compose file patterns to detect (in order of priority - prefer new style first)
|
||||
@@ -67,23 +68,17 @@ async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Count the number of services defined in a compose file
|
||||
* Uses simple regex to count top-level keys under 'services:' section
|
||||
* Parses YAML to reliably count top-level keys under 'services:' section
|
||||
*/
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
|
||||
// Find the services section and count top-level keys
|
||||
const servicesMatch = content.match(/^services:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m) ||
|
||||
content.match(/\nservices:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
|
||||
|
||||
if (!servicesMatch) return 0;
|
||||
|
||||
const servicesBlock = servicesMatch[1];
|
||||
// Count lines that start with exactly 2 spaces followed by a non-space (service names)
|
||||
const serviceLines = servicesBlock.match(/^ [a-zA-Z0-9_-]+:/gm);
|
||||
return serviceLines?.length || 0;
|
||||
const doc = yaml.load(content) as Record<string, unknown> | null;
|
||||
if (doc?.services && typeof doc.services === 'object') {
|
||||
return Object.keys(doc.services).length;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
+127
-117
@@ -6,10 +6,9 @@
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import {
|
||||
getEnvironment,
|
||||
getStackEnvVarsAsRecord,
|
||||
getSecretEnvVarsAsRecord,
|
||||
getNonSecretEnvVarsAsRecord,
|
||||
getStackEnvVars,
|
||||
@@ -95,7 +94,6 @@ export interface DeployStackOptions {
|
||||
name: string;
|
||||
compose: string;
|
||||
envId?: number | null;
|
||||
envFileVars?: Record<string, string>;
|
||||
sourceDir?: string; // Directory to copy all files from (for git stacks)
|
||||
forceRecreate?: boolean;
|
||||
composePath?: string; // Custom compose file path (for adopted/imported stacks)
|
||||
@@ -313,25 +311,6 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List stacks that have compose files stored locally
|
||||
*/
|
||||
export function listManagedStacks(): string[] {
|
||||
const stacksDir = getStacksDir();
|
||||
if (!existsSync(stacksDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return readdirSync(stacksDir, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.filter((dirent) => {
|
||||
// Check all valid compose filenames
|
||||
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
|
||||
return composeNames.some(name => existsSync(join(stacksDir, dirent.name, name)));
|
||||
})
|
||||
.map((dirent) => dirent.name);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPOSE FILE MANAGEMENT
|
||||
// =============================================================================
|
||||
@@ -761,6 +740,8 @@ interface ComposeCommandOptions {
|
||||
composePath?: string;
|
||||
/** Full path to the env file (for --env-file flag, supports custom names) */
|
||||
envPath?: string;
|
||||
/** When true, write non-secret envVars to .env.dockhand override file (git stacks only) */
|
||||
useOverrideFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -785,7 +766,8 @@ async function executeLocalCompose(
|
||||
envId?: number | null,
|
||||
workingDir?: string,
|
||||
customComposePath?: string,
|
||||
customEnvPath?: string
|
||||
customEnvPath?: string,
|
||||
useOverrideFile?: boolean
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
|
||||
@@ -894,12 +876,29 @@ async function executeLocalCompose(
|
||||
const useStdin = finalComposeContent !== composeContent;
|
||||
const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile];
|
||||
|
||||
// Add --env-file flag if env file exists
|
||||
// This makes Docker Compose load the .env file automatically (like Portainer)
|
||||
// Uses custom path if provided, otherwise defaults to .env in stack directory
|
||||
const envFilePath = customEnvPath || join(stackDir, '.env');
|
||||
if (existsSync(envFilePath)) {
|
||||
args.push('--env-file', envFilePath);
|
||||
// Always auto-detect .env in compose directory
|
||||
const defaultEnvPath = join(stackDir, '.env');
|
||||
if (existsSync(defaultEnvPath)) {
|
||||
args.push('--env-file', defaultEnvPath);
|
||||
}
|
||||
|
||||
// Add custom env file if configured and different from auto-detected .env
|
||||
if (customEnvPath && resolve(customEnvPath) !== resolve(defaultEnvPath) && existsSync(customEnvPath)) {
|
||||
args.push('--env-file', customEnvPath);
|
||||
}
|
||||
|
||||
// For git stacks: write non-secret overrides to .env.dockhand and add as second --env-file
|
||||
// Docker Compose applies env files in order, so later files override earlier ones.
|
||||
// This lets the repo's .env provide defaults while our overrides take precedence.
|
||||
// Secrets are still injected via shell env only (never written to disk).
|
||||
// Only written when useOverrideFile is true (git stacks). Internal/adopted stacks
|
||||
// already have their non-secrets in the .env file written by the UI.
|
||||
if (useOverrideFile && envVars && Object.keys(envVars).length > 0) {
|
||||
const overrideEnvPath = join(stackDir, '.env.dockhand');
|
||||
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
|
||||
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
|
||||
await Bun.write(overrideEnvPath, header + lines.join('\n') + '\n');
|
||||
args.push('--env-file', overrideEnvPath);
|
||||
}
|
||||
|
||||
if (useStdin) {
|
||||
@@ -1199,7 +1198,7 @@ async function executeComposeCommand(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath } = options;
|
||||
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1219,7 +1218,8 @@ async function executeComposeCommand(
|
||||
envId,
|
||||
workingDir,
|
||||
composePath,
|
||||
envPath
|
||||
envPath,
|
||||
useOverrideFile
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1263,7 +1263,8 @@ async function executeComposeCommand(
|
||||
envId,
|
||||
workingDir,
|
||||
composePath,
|
||||
envPath
|
||||
envPath,
|
||||
useOverrideFile
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1282,7 +1283,8 @@ async function executeComposeCommand(
|
||||
envId,
|
||||
workingDir,
|
||||
composePath,
|
||||
envPath
|
||||
envPath,
|
||||
useOverrideFile
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1487,7 +1489,6 @@ async function withContainerFallback(
|
||||
export interface RequireComposeResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
envVars?: Record<string, string>;
|
||||
secretVars?: Record<string, string>;
|
||||
needsFileLocation?: boolean;
|
||||
error?: string;
|
||||
@@ -1495,19 +1496,18 @@ export interface RequireComposeResult {
|
||||
stackDir?: string;
|
||||
/** Full path to the compose file (for imported stacks) */
|
||||
composePath?: string;
|
||||
/** Full path to the env file (for --env-file flag) */
|
||||
envPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compose file and env vars for stack operations.
|
||||
* Get compose file and secret vars for stack operations.
|
||||
*
|
||||
* Returns:
|
||||
* - content: The compose file content
|
||||
* - envVars: Non-secret variables (from .env file, with DB fallback)
|
||||
* - secretVars: Secret variables (from DB only, for shell injection)
|
||||
* - envPath: Path to the .env file (Docker Compose reads non-secrets from it)
|
||||
* - needsFileLocation: true if stack needs user to specify file paths
|
||||
*
|
||||
* SECURITY: Secrets are NEVER written to .env files. They are stored in the database
|
||||
* and injected via shell environment variables at runtime.
|
||||
*/
|
||||
async function requireComposeFile(
|
||||
stackName: string,
|
||||
@@ -1534,10 +1534,7 @@ async function requireComposeFile(
|
||||
// These are NEVER written to disk
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Get non-secret variables from database (for backward compatibility)
|
||||
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Read non-secret vars from .env file
|
||||
// Determine env file path for --env-file flag
|
||||
// For stacks with custom composePath (adopted/external), derive envPath from same directory
|
||||
// For internal stacks, use the default data directory
|
||||
let envFilePath: string | null = null;
|
||||
@@ -1560,38 +1557,11 @@ async function requireComposeFile(
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
|
||||
let fileEnvVars: Record<string, string> = {};
|
||||
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1);
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fileEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore file read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Merge non-secret vars: DB as fallback, file values override
|
||||
// This ensures external edits to .env are respected during deployment
|
||||
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
|
||||
|
||||
// Docker Compose reads non-secrets from the .env file via --env-file.
|
||||
// Only secrets need to be injected via shell environment.
|
||||
return {
|
||||
success: true,
|
||||
content: composeResult.content!,
|
||||
envVars,
|
||||
secretVars,
|
||||
stackDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
@@ -1618,7 +1588,7 @@ export async function startStack(
|
||||
'up',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
undefined,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1642,7 +1612,7 @@ export async function stopStack(
|
||||
'stop',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
undefined,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1666,7 +1636,7 @@ export async function restartStack(
|
||||
'restart',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
undefined,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1691,7 +1661,7 @@ export async function downStack(
|
||||
'down',
|
||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
undefined,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1707,7 +1677,7 @@ export async function removeStack(
|
||||
): Promise<StackOperationResult> {
|
||||
return withStackLock(stackName, async () => {
|
||||
// Get compose file (may not exist for external stacks)
|
||||
const composeResult = await getStackComposeFile(stackName);
|
||||
const composeResult = await getStackComposeFile(stackName, envId);
|
||||
|
||||
// Get stack containers BEFORE removing them (for cleanup later)
|
||||
const stackContainers = await getStackContainers(stackName, envId);
|
||||
@@ -1804,16 +1774,17 @@ export async function removeStack(
|
||||
const resolvedStacksDir = resolve(stacksDir);
|
||||
|
||||
// Only delete if the directory is within DATA_DIR/stacks/ (files we created)
|
||||
// AND it looks like a stack directory (contains stackName for safety)
|
||||
// AND the directory basename matches the stack name exactly (for safety)
|
||||
if (resolvedCustomDir.startsWith(resolvedStacksDir) &&
|
||||
customDir.includes(stackName) &&
|
||||
basename(resolvedCustomDir) === stackName &&
|
||||
existsSync(customDir)) {
|
||||
stackDir = customDir;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default paths (always within DATA_DIR/stacks/)
|
||||
if (!stackDir) {
|
||||
// Fall back to default paths ONLY if no custom path was set in DB
|
||||
// (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR)
|
||||
if (!stackDir && !stackSource?.composePath) {
|
||||
const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
|
||||
if (existsSync(defaultDir)) {
|
||||
stackDir = defaultDir;
|
||||
@@ -1853,14 +1824,14 @@ export async function removeStack(
|
||||
const gitStack = await getGitStackByName(stackName, envId);
|
||||
if (gitStack) {
|
||||
await deleteGitStack(gitStack.id);
|
||||
deleteGitStackFiles(gitStack.id);
|
||||
await deleteGitStackFiles(gitStack.id, gitStack.stackName, gitStack.environmentId);
|
||||
}
|
||||
// Also cleanup any orphaned git stacks with NULL environment_id for this stack name
|
||||
if (envId !== undefined && envId !== null) {
|
||||
const orphanedGitStack = await getGitStackByName(stackName, null);
|
||||
if (orphanedGitStack) {
|
||||
await deleteGitStack(orphanedGitStack.id);
|
||||
deleteGitStackFiles(orphanedGitStack.id);
|
||||
await deleteGitStackFiles(orphanedGitStack.id, orphanedGitStack.stackName, orphanedGitStack.environmentId);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1890,7 +1861,7 @@ export async function removeStack(
|
||||
* Uses stack locking to prevent concurrent deployments.
|
||||
*/
|
||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
||||
const { name, compose, envId, envFileVars, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const { name, compose, envId, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const logPrefix = `[Stack:${name}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -1903,11 +1874,6 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
console.log(`${logPrefix} Custom env path:`, envPath ?? '(none)');
|
||||
console.log(`${logPrefix} Compose filename:`, composeFileName ?? '(none)');
|
||||
console.log(`${logPrefix} Env filename:`, envFileName ?? '(none)');
|
||||
console.log(`${logPrefix} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0);
|
||||
if (envFileVars && Object.keys(envFileVars).length > 0) {
|
||||
console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', '));
|
||||
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2));
|
||||
}
|
||||
|
||||
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
|
||||
// Must also start with a letter or number
|
||||
@@ -1943,7 +1909,16 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
if (composeFileName) {
|
||||
actualComposePath = join(workingDir, composeFileName);
|
||||
console.log(`${logPrefix} Using compose filename from git config:`, composeFileName);
|
||||
console.log(`${logPrefix} Actual compose path will be:`, actualComposePath);
|
||||
} else {
|
||||
// Detect compose file in source directory
|
||||
const composeNames = ['docker-compose.yaml', 'docker-compose.yml', 'compose.yaml', 'compose.yml'];
|
||||
for (const cn of composeNames) {
|
||||
if (existsSync(join(sourceDir, cn))) {
|
||||
actualComposePath = join(workingDir, cn);
|
||||
console.log(`${logPrefix} Detected compose file:`, cn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set actualEnvPath using the provided env filename from git stack config
|
||||
@@ -1967,31 +1942,38 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
cpSync(sourceDir, workingDir, { recursive: true });
|
||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
||||
} else {
|
||||
// Internal stack: compose file should already exist (written by saveStackComposeFile)
|
||||
// Just determine the working directory
|
||||
workingDir = await getStackDir(name, envId);
|
||||
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
|
||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
||||
const source = await getStackSource(name, envId);
|
||||
if (source?.composePath) {
|
||||
workingDir = dirname(source.composePath);
|
||||
actualComposePath = source.composePath;
|
||||
if (source.envPath) {
|
||||
actualEnvPath = source.envPath;
|
||||
}
|
||||
console.log(`${logPrefix} Using custom path from DB:`, workingDir);
|
||||
} else {
|
||||
// Default: compose file should already exist (written by saveStackComposeFile)
|
||||
workingDir = await getStackDir(name, envId);
|
||||
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
|
||||
console.log(`${logPrefix} Compose content (full):`);
|
||||
console.log(compose);
|
||||
|
||||
// Fetch stack environment variables from database (these are user overrides)
|
||||
const dbEnvVars = await getStackEnvVarsAsRecord(name, envId);
|
||||
console.log(`${logPrefix} DB env vars count:`, Object.keys(dbEnvVars).length);
|
||||
if (Object.keys(dbEnvVars).length > 0) {
|
||||
console.log(`${logPrefix} DB env var keys:`, Object.keys(dbEnvVars).join(', '));
|
||||
console.log(`${logPrefix} DB env vars (masked):`, JSON.stringify(maskSecrets(dbEnvVars), null, 2));
|
||||
}
|
||||
// Fetch overrides and secrets from DB
|
||||
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(name, envId);
|
||||
const secretVars = await getSecretEnvVarsAsRecord(name, envId);
|
||||
console.log(`${logPrefix} DB non-secret override vars:`, Object.keys(dbNonSecretVars).length);
|
||||
console.log(`${logPrefix} DB secret vars:`, Object.keys(secretVars).length);
|
||||
|
||||
// Merge: env file vars as base, database overrides take precedence
|
||||
const envVars = { ...envFileVars, ...dbEnvVars };
|
||||
console.log(`${logPrefix} Merged env vars count:`, Object.keys(envVars).length);
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
console.log(`${logPrefix} Merged env var keys:`, Object.keys(envVars).join(', '));
|
||||
console.log(`${logPrefix} Merged env vars (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
}
|
||||
// For git stacks (sourceDir provided), use the override file (.env.dockhand)
|
||||
// to layer editor overrides on top of the repo's .env file.
|
||||
// Only DB overrides go into .env.dockhand - repo values are already in the repo's env file.
|
||||
// For internal/adopted stacks, the .env file is already the editor's output,
|
||||
// so no override file is needed - only pass secrets for shell injection.
|
||||
const isGitStack = !!sourceDir;
|
||||
|
||||
console.log(`${logPrefix} Calling executeComposeCommand...`);
|
||||
const result = await executeComposeCommand(
|
||||
@@ -2003,10 +1985,12 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
stackFiles,
|
||||
workingDir,
|
||||
composePath: actualComposePath,
|
||||
envPath: actualEnvPath
|
||||
envPath: actualEnvPath,
|
||||
useOverrideFile: isGitStack
|
||||
},
|
||||
compose,
|
||||
envVars
|
||||
isGitStack ? dbNonSecretVars : undefined,
|
||||
secretVars
|
||||
);
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
console.log(`${logPrefix} DEPLOY STACK RESULT`);
|
||||
@@ -2042,7 +2026,7 @@ export async function pullStackImages(
|
||||
'pull',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
undefined,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -2077,9 +2061,22 @@ export async function writeStackEnvFile(
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
const envFilePath = customEnvPath
|
||||
? customEnvPath
|
||||
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
let envFilePath: string;
|
||||
if (customEnvPath) {
|
||||
envFilePath = customEnvPath;
|
||||
} else {
|
||||
// Check if stack has a custom path in DB
|
||||
const source = await getStackSource(stackName, envId);
|
||||
if (source?.envPath) {
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Derive env path from custom compose path location
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Fall back to default location
|
||||
envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = dirname(envFilePath);
|
||||
@@ -2109,9 +2106,22 @@ export async function writeRawStackEnvFile(
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
const envFilePath = customEnvPath
|
||||
? customEnvPath
|
||||
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
let envFilePath: string;
|
||||
if (customEnvPath) {
|
||||
envFilePath = customEnvPath;
|
||||
} else {
|
||||
// Check if stack has a custom path in DB
|
||||
const source = await getStackSource(stackName, envId);
|
||||
if (source?.envPath) {
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Derive env path from custom compose path location
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Fall back to default location
|
||||
envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = dirname(envFilePath);
|
||||
|
||||
Reference in new issue
Block a user