From 133c9f1e8f5de9775022269a9d32231edf66bf86 Mon Sep 17 00:00:00 2001 From: jarek Date: Mon, 9 Feb 2026 20:50:41 +0100 Subject: [PATCH] 1.0.17 --- package.json | 17 +- src/lib/data/changelog.json | 11 + src/lib/data/dependencies.json | 8 +- src/lib/server/docker.ts | 220 ++++++++++++ src/lib/server/scanner.ts | 21 +- .../scheduler/tasks/container-update.ts | 326 +----------------- .../scheduler/tasks/env-update-check.ts | 67 +--- .../containers/batch-update-stream/+server.ts | 132 ++----- .../api/containers/batch-update/+server.ts | 46 +-- .../containers/AutoUpdateSettings.svelte | 20 +- .../containers/ContainerSettingsTab.svelte | 7 - .../containers/EditContainerModal.svelte | 2 - 12 files changed, 323 insertions(+), 554 deletions(-) diff --git a/package.json b/package.json index 15dc7dc..ea8dd09 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.16", + "version": "1.0.17", "type": "module", "scripts": { "dev": "bunx --bun vite dev", @@ -31,6 +31,21 @@ "test:files": "bun test tests/container-files.test.ts", "test:license": "bun test tests/license.test.ts", "test:activity": "bun test tests/activity-dashboard.test.ts", + "test:health": "bun test tests/health-system.test.ts", + "test:containers:advanced": "bun test tests/container-advanced.test.ts", + "test:networks:advanced": "bun test tests/network-advanced.test.ts", + "test:volumes:advanced": "bun test tests/volume-advanced.test.ts", + "test:prune": "bun test tests/prune-operations.test.ts", + "test:schedules": "bun test tests/schedule-management.test.ts", + "test:preferences": "bun test tests/settings-preferences.test.ts", + "test:stacks:advanced": "bun test tests/stack-advanced.test.ts", + "test:system": "bun test tests/system-info.test.ts", + "test:auth": "bun test tests/auth-settings.test.ts", + "test:config-sets": "bun test tests/config-sets.test.ts", + "test:registries": "bun test tests/registries.test.ts", + "test:activity:advanced": "bun test tests/activity-advanced.test.ts", + "test:env-settings": "bun test tests/environment-settings.test.ts", + "test:git-creds": "bun test tests/git-credentials.test.ts", "test:all": "bun test tests/", "test:quick": "bun test tests/api-smoke.test.ts tests/notifications.test.ts", "test:integration": "bun test tests/api-smoke.test.ts tests/crud-operations.test.ts tests/scheduling.test.ts tests/hawser-connection.test.ts", diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index db47698..4525296 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,15 @@ [ + { + "version": "1.0.17", + "date": "2026-02-09", + "comingSoon": false, + "changes": [ + { "type": "fix", "text": "Fix scanner failure on rootless Docker" }, + { "type": "fix", "text": "Increase Hawser compose operation timeout" }, + { "type": "fix", "text": "Fix regression in stack container updates" } + ], + "imageTag": "fnsys/dockhand:v1.0.17" + }, { "version": "1.0.16", "date": "2026-02-09", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 53e8d93..f943cde 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -5,6 +5,12 @@ "license": "MIT", "repository": "https://github.com/codemirror/autocomplete" }, + { + "name": "@codemirror/commands", + "version": "6.10.0", + "license": "MIT", + "repository": "https://github.com/codemirror/commands" + }, { "name": "@codemirror/commands", "version": "6.10.1", @@ -547,7 +553,7 @@ }, { "name": "svelte", - "version": "5.46.4", + "version": "5.47.1", "license": "MIT", "repository": "https://github.com/sveltejs/svelte" }, diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index b8aa29c..9987469 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -1338,6 +1338,195 @@ export async function createContainer(options: CreateContainerOptions, envId?: n return { id: result.Id, start: () => startContainer(result.Id, envId) }; } +/** + * Recreate a container using full Config/HostConfig passthrough from inspect data. + * Passes Config and HostConfig directly from inspect to create, only changing + * the image. No field mapping or stripping. + * + * Flow: + * 1. Stop container + * 2. Rename to name-old (frees the name for the new container) + * 3. Disconnect all networks (frees static IPs) + * 4. Create new container with original name, one network + * 5. Connect additional networks + * 6. Start new container + * 7. Remove old container + * + * On failure: rollback (rename old back, reconnect networks, restart old) + */ +export async function recreateContainerFromInspect( + inspectData: any, + newImage: string, + envId?: number | null, + log?: (msg: string) => void +): Promise<{ Id: string }> { + const config = inspectData.Config || {}; + const hostConfig = inspectData.HostConfig || {}; + const networks: Record = inspectData.NetworkSettings?.Networks || {}; + const name = inspectData.Name?.replace(/^\//, '') || ''; + const oldContainerId = inspectData.Id; + const wasRunning = inspectData.State?.Running; + + // 1. Stop the container + if (wasRunning) { + log?.('Stopping container...'); + await stopContainer(oldContainerId, envId); + } + + // 2. Rename old container to free the name + log?.('Renaming old container...'); + await dockerFetch( + `/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`, + { method: 'POST' }, + envId + ).then(r => { if (!r.ok) throw new Error('Failed to rename old container'); }); + + // 3. Disconnect all networks from old container (frees static IPs) + // Capture the first network for use during container creation + let initialNetworkName: string | null = null; + let initialNetworkConfig: any = null; + + for (const [netName, netConfig] of Object.entries(networks)) { + const networkId = (netConfig as any).NetworkID; + if (networkId) { + try { + await disconnectContainerFromNetwork(networkId, oldContainerId, true, envId); + } catch { + // Best effort - network may already be disconnected + } + } + + // Use first network for creation + if (!initialNetworkName) { + initialNetworkName = netName; + initialNetworkConfig = netConfig; + } + } + + // Rollback helper: restore old container on failure + const rollback = async () => { + try { + log?.('Rolling back: restoring old container...'); + // Rename back + await dockerFetch( + `/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`, + { method: 'POST' }, + envId + ).catch(() => {}); + + // Reconnect networks using full EndpointSettings from inspect + for (const [, netConfig] of Object.entries(networks)) { + const nc = netConfig as any; + if (nc.NetworkID) { + await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {}); + } + } + + // Restart + if (wasRunning) { + await startContainer(oldContainerId, envId).catch(() => {}); + } + } catch { + log?.('Rollback failed'); + } + }; + + // 4. Build create config - pass Config and HostConfig directly from inspect + const createConfig: any = { + ...config, + Image: newImage, + HostConfig: hostConfig + }; + + // Preserve anonymous volumes from Mounts not in HostConfig.Binds + const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => { + const parts = b.split(':'); + return parts.length >= 2 ? parts[1] : parts[0]; + })); + const mounts = inspectData.Mounts || []; + const additionalBinds: string[] = []; + for (const mount of mounts) { + if (mount.Type === 'volume' && mount.Name && mount.Destination) { + if (!existingBinds.has(mount.Destination)) { + additionalBinds.push(`${mount.Name}:${mount.Destination}`); + } + } + } + if (additionalBinds.length > 0) { + createConfig.HostConfig = { + ...hostConfig, + Binds: [...(hostConfig.Binds || []), ...additionalBinds] + }; + } + + // Docker can only connect to one network at creation. Pass the first network + // from the old container's settings to avoid getting a random bridge IP. + // Clear MacAddress for Docker API < 1.44 compatibility. + if (initialNetworkName && initialNetworkConfig) { + const endpointConfig = { ...initialNetworkConfig }; + delete endpointConfig.MacAddress; + createConfig.NetworkingConfig = { + EndpointsConfig: { + [initialNetworkName]: endpointConfig + } + }; + } + + // 5. Create new container + log?.('Creating new container...'); + let newContainerId: string; + try { + const result = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(name)}`, + { + method: 'POST', + body: JSON.stringify(createConfig) + }, + envId + ); + newContainerId = result.Id; + } catch (createError: any) { + log?.(`Create failed: ${createError.message}`); + await rollback(); + throw createError; + } + + // 6. Connect additional networks using full EndpointSettings from inspect + for (const [netName, netConfig] of Object.entries(networks)) { + if (netName === initialNetworkName) continue; // Already connected at creation + + const nc = netConfig as any; + if (nc.NetworkID) { + try { + await connectContainerToNetworkRaw(nc.NetworkID, newContainerId, nc, envId); + } catch (netError: any) { + log?.(`Warning: Failed to connect to network "${netName}": ${netError.message}`); + } + } + } + + // 7. Start new container + if (wasRunning) { + log?.('Starting new container...'); + try { + await startContainer(newContainerId, envId); + } catch (startError: any) { + log?.(`Start failed: ${startError.message}, rolling back...`); + // Remove failed new container + await removeContainer(newContainerId, true, envId).catch(() => {}); + await rollback(); + throw startError; + } + } + + // 8. Remove old container (best effort) + log?.('Removing old container...'); + await removeContainer(oldContainerId, true, envId).catch(() => {}); + + log?.('Container recreated successfully'); + return { Id: newContainerId }; +} + /** * Extract all container options from Docker inspect data. * This preserves ALL container settings for recreation. @@ -2767,6 +2956,37 @@ export async function connectContainerToNetwork( } } +/** + * Connect a container to a network using a raw EndpointSettings object from inspect data. + * Passes the full EndpointSettings as-is, preserving all fields (Links, DriverOpts, + * IPAMConfig.LinkLocalIPs, MacAddress, etc.) without manual field extraction. + */ +export async function connectContainerToNetworkRaw( + networkId: string, + containerId: string, + endpointSettings: any, + envId?: number | null +): Promise { + const body: any = { + Container: containerId, + EndpointConfig: endpointSettings + }; + + const response = await dockerFetch( + `/networks/${networkId}/connect`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }, + envId + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to connect container to network'); + } +} + export async function disconnectContainerFromNetwork( networkId: string, containerId: string, diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index ef9da33..d2af3b2 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -593,19 +593,21 @@ async function runScannerContainerCore( (connectionType === 'direct' && !env?.host); let hostSocketPath: string; - let containerUser: string | undefined; + let rootlessUid: string | undefined; if (isLocalSocket) { // Local socket environment - detect host socket path (handles rootless Docker) hostSocketPath = getHostDockerSocket(); console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`); - // For user-specific Docker sockets, run scanner as that user - // e.g., /run/user/1000/docker.sock -> run as UID 1000 + // For user-specific Docker sockets (rootless Docker), detect UID for cache ownership + // but do NOT set container user — in rootless Docker, root inside the container + // maps to the socket-owning UID on the host via user namespace remapping const uid = extractUidFromSocketPath(hostSocketPath); if (uid) { - containerUser = uid; - console.log(`[Scanner] Rootless Docker detected (UID ${containerUser})`); + rootlessUid = uid; + console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`); + console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`); } } else { // Remote environment (direct with host/hawser-standard/hawser-edge) @@ -620,9 +622,9 @@ async function runScannerContainerCore( let cacheBind: string; const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME; - if (containerUser) { + if (rootlessUid) { // Rootless Docker: use bind mount from data directory with correct ownership - const hostCachePath = await ensureScannerCacheDir(scannerType, containerUser); + const hostCachePath = await ensureScannerCacheDir(scannerType, rootlessUid); cacheBind = `${hostCachePath}:${basePath}`; console.log(`[Scanner] Rootless mode - using bind mount: ${cacheBind}`); } else { @@ -646,10 +648,6 @@ async function runScannerContainerCore( 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`); - } - // Run the scanner container with a 10-minute timeout to prevent indefinite hangs const output = await runContainerWithStreaming({ image: scannerImage, @@ -657,7 +655,6 @@ async function runScannerContainerCore( binds, env: envVars, name: `dockhand-${scannerType}-${Date.now()}`, - user: containerUser, envId, timeout: 600_000, // 10 minutes onStderr: (data) => { diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index bcb863b..6fedb74 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -2,13 +2,9 @@ * Container Auto-Update Task * * Handles automatic container updates with vulnerability scanning. - * - * For containers that are part of a Docker Compose stack, updates use - * `docker compose up -d` to preserve ALL configuration from the compose file - * (network aliases, static IPs, health checks, resource limits, etc.). - * - * For standalone containers, updates use container recreation with comprehensive - * settings preservation. + * Uses direct Docker API recreation with full Config/HostConfig passthrough + * from inspect data. No compose commands for + * individual container updates, no manual field mapping, zero settings loss. */ import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db'; @@ -26,7 +22,6 @@ import { pullImage, listContainers, inspectContainer, - createContainer, stopContainer, startContainer, removeContainer, @@ -37,12 +32,12 @@ import { removeTempImage, tagImage, connectContainerToNetwork, - extractContainerOptions + disconnectContainerFromNetwork, + recreateContainerFromInspect } from '../../docker'; import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { sendEventNotification } from '../../notifications'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; -import { getStackComposeFile, updateStackService, pullStackService } from '../../stacks'; // ============================================================================= // TYPES @@ -392,19 +387,6 @@ export async function runContainerUpdate( log(`Container is using image: ${imageNameFromConfig}`); log(`Current image ID: ${currentImageId?.substring(0, 19)}`); - // Detect if container is part of a Docker Compose stack - const containerLabels = inspectData.Config?.Labels || {}; - const composeProject = containerLabels['com.docker.compose.project']; - const composeService = containerLabels['com.docker.compose.service']; - const composeConfigFiles = containerLabels['com.docker.compose.project.config_files']; - const isStackContainer = !!(composeProject && composeService); - - if (isStackContainer) { - log(`Container is part of compose stack: ${composeProject} (service: ${composeService}, configFiles: ${composeConfigFiles || 'none'})`); - } else { - log(`Container is standalone (not part of a compose stack)`); - } - // Get scanner and schedule settings early to determine scan strategy const [scannerSettings, updateSetting] = await Promise.all([ getScannerSettings(envId), @@ -458,150 +440,7 @@ export async function runContainerUpdate( const newDigest = registryCheck.registryDigest; // ============================================================================= - // STACK CONTAINER: Compose-native flow - // ============================================================================= - // 1. Check if we have the compose file - // 2. docker compose pull - // 3. Scan if enabled, block if needed - // 4. docker compose up -d - // ============================================================================= - - if (isStackContainer) { - const composeResult = await getStackComposeFile(composeProject, envId, composeConfigFiles); - log(`Compose lookup result: success=${composeResult.success}, composePath=${composeResult.composePath || 'none'}`); - - if (composeResult.success) { - log(`Using compose-native update for stack: ${composeProject}`); - - try { - // Pull via docker compose - log(`Running: docker compose pull ${composeService}`); - const pullResult = await pullStackService(composeProject, composeService, envId, composeConfigFiles); - if (!pullResult.success) { - throw new Error(pullResult.error || 'docker compose pull failed'); - } - log(`Compose pull completed`); - - // Get new image ID - const newImageId = await getImageIdByTag(imageNameFromConfig, envId); - if (!newImageId) { - throw new Error('Failed to get new image ID after compose pull'); - } - log(`New image ID: ${newImageId.substring(0, 19)}`); - - // Scan if enabled - let scanOutcome: ScanOutcome = { blocked: false }; - if (shouldScan) { - try { - scanOutcome = await scanAndCheckBlock({ - newImageId, - currentImageId, - envId, - vulnerabilityCriteria, - log - }); - - if (scanOutcome.blocked) { - // Restore old tag so container keeps using safe image - log(`Restoring original tag to safe image...`); - const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig); - await tagImage(currentImageId, oldRepo, oldTag, envId); - - await updateScheduleExecution(execution.id, { - status: 'skipped', - completedAt: new Date().toISOString(), - duration: Date.now() - startTime, - details: buildBlockedDetails( - containerName, - vulnerabilityCriteria, - scanOutcome.reason!, - scanOutcome.scanResults!, - scanOutcome.scanSummary! - ) - }); - - await sendEventNotification('auto_update_blocked', { - title: 'Auto-update blocked', - message: `Container "${containerName}" update blocked: ${scanOutcome.reason}`, - type: 'warning' - }, envId); - - return; - } - } catch (scanError: any) { - log(`Scan failed: ${scanError.message}`); - log(`Restoring original tag...`); - const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig); - await tagImage(currentImageId, oldRepo, oldTag, envId); - - await updateScheduleExecution(execution.id, { - status: 'failed', - completedAt: new Date().toISOString(), - duration: Date.now() - startTime, - errorMessage: `Vulnerability scan failed: ${scanError.message}` - }); - return; - } - } - - // Apply update via docker compose up - log(`Running: docker compose up -d ${composeService}`); - const upResult = await updateStackService(composeProject, composeService, envId, composeConfigFiles); - if (!upResult.success) { - throw new Error(upResult.error || 'docker compose up failed'); - } - - // Success - await updateAutoUpdateLastUpdated(containerName, envId); - log(`Successfully updated container: ${containerName}`); - - await updateScheduleExecution(execution.id, { - status: 'success', - completedAt: new Date().toISOString(), - duration: Date.now() - startTime, - details: buildSuccessDetails( - containerName, - newDigest, - vulnerabilityCriteria, - scanOutcome.scanResults, - scanOutcome.scanSummary - ) - }); - - await sendEventNotification('auto_update_success', { - title: 'Container auto-updated', - message: `Container "${containerName}" was updated to a new image version`, - type: 'success' - }, envId); - - return; - - } catch (composeError: any) { - log(`Compose update failed: ${composeError.message}`); - await updateScheduleExecution(execution.id, { - status: 'failed', - completedAt: new Date().toISOString(), - duration: Date.now() - startTime, - errorMessage: `Stack update failed: ${composeError.message}` - }); - - await sendEventNotification('auto_update_failed', { - title: 'Auto-update failed', - message: `Container "${containerName}" auto-update failed: ${composeError.message}`, - type: 'error' - }, envId); - - return; - } - } - - // No compose file found - fall through to standalone flow - log(`No compose file found for stack "${composeProject}" - using standalone update`); - log(`TIP: Import this stack into Dockhand for compose-native updates`); - } - - // ============================================================================= - // STANDALONE CONTAINER: Temp-tag protection flow + // PULL & SCAN: Temp-tag protection flow // ============================================================================= // 1. Pull new image (overwrites tag) // 2. Restore original tag to OLD image (safety) @@ -726,16 +565,10 @@ export async function runContainerUpdate( } // ============================================================================= - // RECREATE CONTAINER + // RECREATE CONTAINER (full config passthrough from inspect data) // ============================================================================= - if (isStackContainer) { - log(`External stack - recreating container directly`); - log(`WARNING: Some compose settings may not be preserved`); - } else { - log(`Recreating standalone container...`); - } - + log(`Recreating container with full config passthrough...`); const success = await recreateContainer(containerName, envId, log); if (success) { @@ -786,8 +619,9 @@ export async function runContainerUpdate( // ============================================================================= /** - * Recreate a standalone container with comprehensive settings preservation. - * Extracts and preserves 50+ container settings from the original container. + * Recreate a container using full Config/HostConfig passthrough from inspect data. + * Passes inspect data directly to Docker API create, only changing the image. + * No manual field mapping — zero settings loss. */ export async function recreateContainer( containerName: string, @@ -804,104 +638,11 @@ export async function recreateContainer( } const inspectData = await inspectContainer(container.id, envId) as any; - const wasRunning = inspectData.State.Running; - const hostConfig = inspectData.HostConfig; - const config = inspectData.Config; + const imageName = inspectData.Config?.Image; - log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`); - log?.(`Preserving all container settings...`); + log?.(`Recreating container: ${containerName} (image: ${imageName})`); - if (wasRunning) { - log?.('Stopping container...'); - await stopContainer(container.id, envId); - } - - log?.('Removing old container...'); - await removeContainer(container.id, true, envId); - - const containerOptions = extractContainerOptions(inspectData); - - // Handle additional networks - const networkSettings = inspectData.NetworkSettings?.Networks || {}; - const primaryNetwork = hostConfig.NetworkMode || 'bridge'; - const shortContainerId = container.id.substring(0, 12); - const composeProject = config.Labels?.['com.docker.compose.project']; - const composeService = config.Labels?.['com.docker.compose.service']; - - interface NetworkInfo { - name: string; - aliases: string[]; - ipv4Address: string | undefined; - ipv6Address: string | undefined; - gwPriority: number | undefined; - } - - const additionalNetworks: NetworkInfo[] = []; - - 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) { - if (containerOptions.networkAliases?.length) { - log?.(`Primary network aliases: ${containerOptions.networkAliases.join(', ')}`); - } - if (containerOptions.networkIpv4Address) { - log?.(`Primary network static IPv4: ${containerOptions.networkIpv4Address}`); - } - } else { - const secondaryAliases = ((netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || []) - .filter((a: string) => a !== container.id && a !== shortContainerId); - - 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: secondaryAliases, - ipv4Address: netConf.IPAMConfig?.IPv4Address || undefined, - ipv6Address: netConf.IPAMConfig?.IPv6Address || undefined, - gwPriority: netConf.GwPriority !== undefined && netConf.GwPriority !== 0 - ? netConf.GwPriority : undefined - }); - } - } - - if (additionalNetworks.length > 0) { - log?.(`Will reconnect to ${additionalNetworks.length} additional network(s)`); - } - - log?.('Creating new container...'); - const newContainer = await createContainer(containerOptions, envId); - - for (const netInfo of additionalNetworks) { - try { - await connectContainerToNetwork(netInfo.name, newContainer.id, envId, { - aliases: netInfo.aliases.length > 0 ? netInfo.aliases : undefined, - ipv4Address: netInfo.ipv4Address, - ipv6Address: netInfo.ipv6Address, - gwPriority: netInfo.gwPriority - }); - log?.(` Connected to: ${netInfo.name}`); - } catch (netError: any) { - log?.(` Warning: Failed to connect to "${netInfo.name}": ${netError.message}`); - } - } - - if (wasRunning) { - log?.('Starting new container...'); - await newContainer.start(); - } - - log?.('Container recreated successfully'); + await recreateContainerFromInspect(inspectData, imageName, envId, log); return true; } catch (error: any) { log?.(`Failed to recreate container: ${error.message}`); @@ -909,42 +650,3 @@ export async function recreateContainer( } } -/** - * Update a container that is part of a Docker Compose stack. - * Uses `docker compose up -d ` which preserves all compose configuration. - * - * @returns true if update succeeded, false if stack not found (use fallback) - */ -export async function updateStackContainer( - stackName: string, - serviceName: string, - envId?: number, - log?: (msg: string) => void, - composeConfigPath?: string -): Promise { - try { - log?.(`Looking up stack: ${stackName}`); - - const composeResult = await getStackComposeFile(stackName, envId, composeConfigPath); - - if (!composeResult.success || !composeResult.content) { - log?.(`No compose file found for stack "${stackName}"`); - log?.(`TIP: Import the stack in Dockhand for compose-native updates`); - return false; - } - - log?.(`Running: docker compose up -d ${serviceName}`); - const result = await updateStackService(stackName, serviceName, envId, composeConfigPath); - - if (result.success) { - log?.(`Service ${serviceName} updated via docker compose`); - return true; - } else { - log?.(`docker compose up failed: ${result.error || 'Unknown error'}`); - return false; - } - } catch (error: any) { - log?.(`Stack update error: ${error.message}`); - return false; - } -} diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts index ae0e186..68e34f7 100644 --- a/src/lib/server/scheduler/tasks/env-update-check.ts +++ b/src/lib/server/scheduler/tasks/env-update-check.ts @@ -26,13 +26,12 @@ import { isDigestBasedImage, getImageIdByTag, removeTempImage, - tagImage + tagImage, } from '../../docker'; import { sendEventNotification } from '../../notifications'; import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; -import { recreateContainer, updateStackContainer } from './container-update'; -import { pullStackService } from '../../stacks'; +import { recreateContainer } from './container-update'; interface UpdateInfo { containerId: string; @@ -236,34 +235,14 @@ export async function runEnvUpdateCheckJob( try { await log(`\nUpdating: ${update.containerName}`); - // Get full container config - const inspectData = await inspectContainer(update.containerId, environmentId) as any; - const containerConfig = inspectData.Config; - - // Detect stack membership early (needed for both pull and recreate) - const containerLabels = containerConfig.Labels || {}; - const composeProject = containerLabels['com.docker.compose.project']; - const composeService = containerLabels['com.docker.compose.service']; - const composeConfigFiles = containerLabels['com.docker.compose.project.config_files']; - const isStackContainer = !!(composeProject && composeService); - // SAFE-PULL FLOW if (shouldScan && !isDigestBasedImage(update.imageName)) { const tempTag = getTempImageTag(update.imageName); await log(` Safe-pull with temp tag: ${tempTag}`); // Step 1: Pull new image - if (isStackContainer) { - await log(` Pulling via compose (stack: ${composeProject}, service: ${composeService})...`); - const pullResult = await pullStackService(composeProject, composeService, environmentId, composeConfigFiles); - if (!pullResult.success) { - await log(` Compose pull failed, falling back to direct pull...`); - await pullImage(update.imageName, () => {}, environmentId); - } - } else { - await log(` Pulling ${update.imageName}...`); - await pullImage(update.imageName, () => {}, environmentId); - } + await log(` Pulling ${update.imageName}...`); + await pullImage(update.imageName, () => {}, environmentId); // Step 2: Get new image ID const newImageId = await getImageIdByTag(update.imageName, environmentId); @@ -372,39 +351,15 @@ export async function runEnvUpdateCheckJob( } catch { /* ignore cleanup errors */ } } else { // Simple pull (no scanning or digest-based image) - if (isStackContainer) { - await log(` Pulling via compose (stack: ${composeProject}, service: ${composeService})...`); - const pullResult = await pullStackService(composeProject, composeService, environmentId, composeConfigFiles); - if (!pullResult.success) { - await log(` Compose pull failed, falling back to direct pull...`); - await pullImage(update.imageName, () => {}, environmentId); - } - } else { - await log(` Pulling ${update.imageName}...`); - await pullImage(update.imageName, () => {}, environmentId); - } + await log(` Pulling ${update.imageName}...`); + await pullImage(update.imageName, () => {}, environmentId); } - // Recreate container using compose-native or full recreation - if (isStackContainer) { - await log(` Updating via compose (stack: ${composeProject}, service: ${composeService})`); - const stackSuccess = await updateStackContainer( - composeProject, composeService, environmentId, - (msg) => { log(` ${msg}`); }, - composeConfigFiles - ); - if (!stackSuccess) { - await log(` Compose file not found, falling back to container recreation...`); - const ok = await recreateContainer(update.containerName, environmentId, - (msg) => { log(` ${msg}`); }); - if (!ok) throw new Error('Container recreation failed'); - } - } else { - await log(` Recreating standalone container...`); - const ok = await recreateContainer(update.containerName, environmentId, - (msg) => { log(` ${msg}`); }); - if (!ok) throw new Error('Container recreation failed'); - } + // Recreate container with full config passthrough + await log(` Recreating container...`); + const ok = await recreateContainer(update.containerName, environmentId, + (msg) => { log(` ${msg}`); }); + if (!ok) throw new Error('Container recreation failed'); await log(` Updated successfully`); successCount++; diff --git a/src/routes/api/containers/batch-update-stream/+server.ts b/src/routes/api/containers/batch-update-stream/+server.ts index a18657b..ffb7f4b 100644 --- a/src/routes/api/containers/batch-update-stream/+server.ts +++ b/src/routes/api/containers/batch-update-stream/+server.ts @@ -15,8 +15,7 @@ import { auditContainer } from '$lib/server/audit'; import { getScannerSettings, scanImage } from '$lib/server/scanner'; import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils'; -import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update'; -import { pullStackService } from '$lib/server/stacks'; +import { recreateContainer } from '$lib/server/scheduler/tasks/container-update'; export interface ScanResult { critical: number; @@ -208,13 +207,6 @@ export const POST: RequestHandler = async (event) => { continue; } - // Detect stack membership early (needed for both pull and recreate) - const containerLabels = config.Labels || {}; - const composeProject = containerLabels['com.docker.compose.project']; - const composeService = containerLabels['com.docker.compose.service']; - const composeConfigFiles = containerLabels['com.docker.compose.project.config_files']; - const isStackContainer = !!(composeProject && composeService); - // Step 1: Pull latest image safeEnqueue({ type: 'progress', @@ -227,37 +219,18 @@ export const POST: RequestHandler = async (event) => { }); try { - if (isStackContainer) { - const pullResult = await pullStackService(composeProject, composeService!, envIdNum, composeConfigFiles); - if (!pullResult.success) { - // Fallback to direct pull - await pullImage(imageName, (data: any) => { - if (data.status) { - safeEnqueue({ - type: 'pull_log', - containerId, - containerName, - pullStatus: data.status, - pullId: data.id, - pullProgress: data.progress - }); - } - }, envIdNum); + await pullImage(imageName, (data: any) => { + if (data.status) { + safeEnqueue({ + type: 'pull_log', + containerId, + containerName, + pullStatus: data.status, + pullId: data.id, + pullProgress: data.progress + }); } - } else { - await pullImage(imageName, (data: any) => { - if (data.status) { - safeEnqueue({ - type: 'pull_log', - containerId, - containerName, - pullStatus: data.status, - pullId: data.id, - pullProgress: data.progress - }); - } - }, envIdNum); - } + }, envIdNum); } catch (pullError: any) { safeEnqueue({ type: 'progress', @@ -481,73 +454,22 @@ export const POST: RequestHandler = async (event) => { let updateSuccess = false; let newContainerId = containerId; - if (isStackContainer) { - // =================================================================== - // STACK CONTAINER: Use docker compose up -d to preserve ALL settings - // =================================================================== - safeEnqueue({ - type: 'progress', - containerId, - containerName, - step: 'creating', - current: i + 1, - total: containerIds.length, - message: `Updating stack ${composeProject} (service: ${composeService})...` - }); + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'creating', + current: i + 1, + total: containerIds.length, + message: `Recreating ${containerName}...` + }); - // Try stack-based update first - const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum, logProgress, composeConfigFiles); - - if (stackSuccess) { - updateSuccess = true; - // Find the new container ID - const updatedContainers = await listContainers(true, envIdNum); - const updatedContainer = updatedContainers.find(c => c.name === containerName); - if (updatedContainer) { - newContainerId = updatedContainer.id; - } - } else { - // Fallback: Stack is external, use container recreation with full settings - safeEnqueue({ - type: 'progress', - containerId, - containerName, - step: 'creating', - current: i + 1, - total: containerIds.length, - message: `Recreating ${containerName} (external stack, preserving all settings)...` - }); - - updateSuccess = await recreateContainer(containerName, envIdNum, logProgress); - if (updateSuccess) { - const updatedContainers = await listContainers(true, envIdNum); - const updatedContainer = updatedContainers.find(c => c.name === containerName); - if (updatedContainer) { - newContainerId = updatedContainer.id; - } - } - } - } else { - // =================================================================== - // STANDALONE CONTAINER: Use shared recreation with ALL settings - // =================================================================== - safeEnqueue({ - type: 'progress', - containerId, - containerName, - step: 'creating', - current: i + 1, - total: containerIds.length, - message: `Recreating ${containerName} (preserving all settings)...` - }); - - updateSuccess = await recreateContainer(containerName, envIdNum, logProgress); - if (updateSuccess) { - const updatedContainers = await listContainers(true, envIdNum); - const updatedContainer = updatedContainers.find(c => c.name === containerName); - if (updatedContainer) { - newContainerId = updatedContainer.id; - } + updateSuccess = await recreateContainer(containerName, envIdNum, logProgress); + if (updateSuccess) { + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; } } diff --git a/src/routes/api/containers/batch-update/+server.ts b/src/routes/api/containers/batch-update/+server.ts index 5f1572d..9711714 100644 --- a/src/routes/api/containers/batch-update/+server.ts +++ b/src/routes/api/containers/batch-update/+server.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { listContainers, pullImage, inspectContainer } from '$lib/server/docker'; import { auditContainer } from '$lib/server/audit'; -import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update'; +import { recreateContainer } from '$lib/server/scheduler/tasks/container-update'; export interface BatchUpdateResult { containerId: string; @@ -75,47 +75,15 @@ export const POST: RequestHandler = async (event) => { continue; } - // Detect if container is part of a Docker Compose stack - const containerLabels = config.Labels || {}; - const composeProject = containerLabels['com.docker.compose.project']; - const composeService = containerLabels['com.docker.compose.service']; - const isStackContainer = !!composeProject; - let updateSuccess = false; let newContainerId = containerId; - if (isStackContainer) { - // Stack container: Try docker compose up -d first - const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum); - - if (stackSuccess) { - updateSuccess = true; - // Find the new container ID - const updatedContainers = await listContainers(true, envIdNum); - const updatedContainer = updatedContainers.find(c => c.name === containerName); - if (updatedContainer) { - newContainerId = updatedContainer.id; - } - } else { - // Fallback: Stack is external, use container recreation - updateSuccess = await recreateContainer(containerName, envIdNum); - if (updateSuccess) { - const updatedContainers = await listContainers(true, envIdNum); - const updatedContainer = updatedContainers.find(c => c.name === containerName); - if (updatedContainer) { - newContainerId = updatedContainer.id; - } - } - } - } else { - // Standalone container: Use shared recreation with ALL settings - updateSuccess = await recreateContainer(containerName, envIdNum); - if (updateSuccess) { - const updatedContainers = await listContainers(true, envIdNum); - const updatedContainer = updatedContainers.find(c => c.name === containerName); - if (updatedContainer) { - newContainerId = updatedContainer.id; - } + updateSuccess = await recreateContainer(containerName, envIdNum); + if (updateSuccess) { + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; } } diff --git a/src/routes/containers/AutoUpdateSettings.svelte b/src/routes/containers/AutoUpdateSettings.svelte index ea52eb1..28d0e2e 100644 --- a/src/routes/containers/AutoUpdateSettings.svelte +++ b/src/routes/containers/AutoUpdateSettings.svelte @@ -4,7 +4,7 @@ import CronEditor from '$lib/components/cron-editor.svelte'; import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; import { currentEnvironment } from '$lib/stores/environment'; - import { Ship, Cable, ExternalLink, AlertTriangle, Info, Layers } from 'lucide-svelte'; + import { Ship, Cable, ExternalLink, AlertTriangle, Info } from 'lucide-svelte'; import type { SystemContainerType } from '$lib/types'; interface Props { @@ -12,8 +12,6 @@ cronExpression: string; vulnerabilityCriteria: VulnerabilityCriteria; systemContainer?: SystemContainerType | null; - isComposeContainer?: boolean; - composeStackName?: string; onenablechange?: (enabled: boolean) => void; oncronchange?: (cron: string) => void; oncriteriachange?: (criteria: VulnerabilityCriteria) => void; @@ -24,8 +22,6 @@ cronExpression = $bindable(), vulnerabilityCriteria = $bindable(), systemContainer = null, - isComposeContainer = false, - composeStackName = '', onenablechange, oncronchange, oncriteriachange @@ -97,20 +93,6 @@ /> - {#if isComposeContainer && enabled} -
- -
-

Stack container update behavior

-

- This container is part of the {composeStackName} stack. - Updates will use docker compose up -d - to preserve all configuration from the compose file. -

-
-
- {/if} - {#if enabled} diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index d9e9511..7dbabaa 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -1108,8 +1108,6 @@ bind:autoUpdateEnabled bind:autoUpdateCronExpression bind:vulnerabilityCriteria - {isComposeContainer} - {composeStackName} {configSets} bind:selectedConfigSetId bind:errors