1.0.17
This commit is contained in:
1 parent
cb8be12f1a
commit
133c9f1e8f
12 files changed
+323
-554
No files matched your search
@@ -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<string, any> = 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<void> {
|
||||
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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in new issue
Block a user