Compare commits

...

1 Commits

Author SHA1 Message Date
jarek 988e65bd5b 1.0.17 2026-02-09 20:50:41 +01:00
12 changed files with 323 additions and 554 deletions
+16 -1
View File
@@ -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",
+11
View File
@@ -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",
+7 -1
View File
@@ -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"
},
+220
View File
@@ -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,
+9 -12
View File
@@ -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++;
@@ -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;
}
}
@@ -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;
}
}
@@ -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 @@
/>
</div>
{#if isComposeContainer && enabled}
<div class="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
<Layers class="mt-0.5 h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
<div class="text-xs text-blue-800 dark:text-blue-200">
<p class="font-medium">Stack container update behavior</p>
<p class="mt-1 text-blue-700 dark:text-blue-300">
This container is part of the <strong>{composeStackName}</strong> stack.
Updates will use <code class="rounded bg-blue-100 px-1 dark:bg-blue-900">docker compose up -d</code>
to preserve all configuration from the compose file.
</p>
</div>
</div>
{/if}
{#if enabled}
<CronEditor
value={cronExpression}
@@ -125,9 +125,6 @@
autoUpdateEnabled: boolean;
autoUpdateCronExpression: string;
vulnerabilityCriteria: VulnerabilityCriteria;
// Compose stack info
isComposeContainer?: boolean;
composeStackName?: string;
// Config sets
configSets: ConfigSet[];
selectedConfigSetId: string;
@@ -192,8 +189,6 @@
autoUpdateEnabled = $bindable(),
autoUpdateCronExpression = $bindable(),
vulnerabilityCriteria = $bindable(),
isComposeContainer = false,
composeStackName = '',
configSets,
selectedConfigSetId = $bindable(),
errors = $bindable(),
@@ -1468,8 +1463,6 @@
bind:cronExpression={autoUpdateCronExpression}
bind:vulnerabilityCriteria={vulnerabilityCriteria}
systemContainer={detectSystemContainer(image)}
{isComposeContainer}
{composeStackName}
/>
</div>
</div>
@@ -1108,8 +1108,6 @@
bind:autoUpdateEnabled
bind:autoUpdateCronExpression
bind:vulnerabilityCriteria
{isComposeContainer}
{composeStackName}
{configSets}
bind:selectedConfigSetId
bind:errors