This commit is contained in:
jarek committed 2026-02-09 20:50:41 +01:00
1 parent cb8be12f1a
commit 133c9f1e8f
12 files changed
+323 -554

No files matched your search

@@ -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 <service>
// 3. Scan if enabled, block if needed
// 4. docker compose up -d <service>
// =============================================================================
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 <service>` 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<boolean> {
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;
}
}
@@ -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++;