This commit is contained in:
jarek committed 2026-01-28 07:34:12 +01:00
1 parent a88d3d5788
commit 8f3a7eb435
96 files changed
+5235 -1870

No files matched your search

+146
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
+77 -3
View File
@@ -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 -12
View File
@@ -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
View File
@@ -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);