This commit is contained in:
jarek committed 2026-01-20 15:39:08 +01:00
1 parent fe48d63164
commit a3cc26d958
35 files changed
+2045 -273

No files matched your search

@@ -4,9 +4,6 @@ import { authorize } from '$lib/server/authorize';
import {
listContainers,
inspectContainer,
stopContainer,
removeContainer,
createContainer,
pullImage,
getTempImageTag,
isDigestBasedImage,
@@ -18,6 +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';
export interface ScanResult {
critical: number;
@@ -401,81 +399,115 @@ export const POST: RequestHandler = async (event) => {
} catch { /* ignore cleanup errors */ }
}
// Step 3: Stop container if running
if (wasRunning) {
// 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;
// Progress logging function for shared functions
const logProgress = (message: string) => {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'stopping',
step: 'creating',
current: i + 1,
total: containerIds.length,
message: `Stopping ${containerName}...`
message
});
await stopContainer(containerId, envIdNum);
}
};
// Step 4: Remove old container
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'removing',
current: i + 1,
total: containerIds.length,
message: `Removing old container ${containerName}...`
});
await removeContainer(containerId, true, envIdNum);
let updateSuccess = false;
let newContainerId = containerId;
// Prepare port bindings
const ports: { [key: string]: { HostPort: string } } = {};
if (hostConfig.PortBindings) {
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
if (bindings && (bindings as any[]).length > 0) {
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
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})...`
});
// Try stack-based update first
const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum, logProgress);
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;
}
}
}
// Step 5: Create new container
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message: `Creating new container ${containerName}...`
});
const newContainer = await createContainer({
name: containerName,
image: imageName,
ports,
volumeBinds: hostConfig.Binds || [],
env: config.Env || [],
labels: config.Labels || {},
cmd: config.Cmd || undefined,
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
networkMode: hostConfig.NetworkMode || undefined
}, envIdNum);
// Step 6: Start if was running
if (wasRunning) {
if (!updateSuccess) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'starting',
step: 'failed',
current: i + 1,
total: containerIds.length,
message: `Starting ${containerName}...`
success: false,
error: 'Container recreation failed'
});
await newContainer.start();
failCount++;
continue;
}
// Audit log
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
// Done with this container - use original containerId for UI consistency
safeEnqueue({
@@ -1,15 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
listContainers,
inspectContainer,
stopContainer,
removeContainer,
createContainer,
pullImage
} from '$lib/server/docker';
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update';
export interface BatchUpdateResult {
containerId: string;
@@ -20,6 +14,8 @@ export interface BatchUpdateResult {
/**
* Batch update containers by recreating them with latest images.
* Preserves ALL container settings including health checks, resource limits,
* capabilities, DNS, security options, ulimits, and network connections.
* Expects JSON body: { containerIds: string[] }
*/
export const POST: RequestHandler = async (event) => {
@@ -62,9 +58,7 @@ export const POST: RequestHandler = async (event) => {
// Get full container config
const inspectData = await inspectContainer(containerId, envIdNum) as any;
const wasRunning = inspectData.State.Running;
const config = inspectData.Config;
const hostConfig = inspectData.HostConfig;
const imageName = config.Image;
const containerName = container.name;
@@ -81,47 +75,65 @@ export const POST: RequestHandler = async (event) => {
continue;
}
// Stop container if running
if (wasRunning) {
await stopContainer(containerId, envIdNum);
}
// 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;
// Remove old container
await removeContainer(containerId, true, envIdNum);
let updateSuccess = false;
let newContainerId = containerId;
// Prepare port bindings
const ports: { [key: string]: { HostPort: string } } = {};
if (hostConfig.PortBindings) {
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
if (bindings && (bindings as any[]).length > 0) {
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
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;
}
}
}
// Create new container
const newContainer = await createContainer({
name: containerName,
image: imageName,
ports,
volumeBinds: hostConfig.Binds || [],
env: config.Env || [],
labels: config.Labels || {},
cmd: config.Cmd || undefined,
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
networkMode: hostConfig.NetworkMode || undefined
}, envIdNum);
// Start if was running
if (wasRunning) {
await newContainer.start();
if (!updateSuccess) {
results.push({
containerId,
containerName,
success: false,
error: 'Container recreation failed'
});
continue;
}
// Audit log
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
results.push({
containerId: newContainer.id,
containerId: newContainerId,
containerName,
success: true
});
+5 -4
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectImage, tagImage, pushImage } from '$lib/server/docker';
import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker';
import { getRegistry, getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
@@ -69,8 +69,8 @@ export const POST: RequestHandler = async (event) => {
}
// Build the target tag
const registryUrl = new URL(registry.url);
const registryHost = registryUrl.host;
// Parse registry URL to get host and org path separately
const { host: registryHost, fullRegistry } = parseRegistryUrl(registry.url);
// Check if this is Docker Hub
const isDockerHub = registryHost.includes('docker.io') ||
@@ -81,7 +81,8 @@ export const POST: RequestHandler = async (event) => {
// Use custom tag if provided, otherwise use the base image name
const targetImageName = newTag || baseImageName;
// Docker Hub doesn't need host prefix - just username/image:tag
const targetTag = isDockerHub ? targetImageName : `${registryHost}/${targetImageName}`;
// For other registries, use full registry path including org (e.g., registry.example.com/org/image:tag)
const targetTag = isDockerHub ? targetImageName : `${fullRegistry}/${targetImageName}`;
// Parse repo and tag properly (handle registry:port/image:tag format)
// Find the last colon that's after the last slash (that's the tag separator)
+8 -2
View File
@@ -24,7 +24,7 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
const { baseUrl, orgPath, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
// Build catalog URL with pagination
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
@@ -58,7 +58,13 @@ export const GET: RequestHandler = async ({ url }) => {
const data = await response.json();
// The V2 API returns { repositories: [...] }
const repositories: string[] = data.repositories || [];
let repositories: string[] = data.repositories || [];
// If the registry URL has an organization path, filter to only show repos under that path
if (orgPath) {
const orgPrefix = orgPath.replace(/^\//, ''); // Remove leading slash
repositories = repositories.filter(repo => repo.startsWith(orgPrefix + '/') || repo === orgPrefix);
}
// Parse Link header for pagination
// Format: </v2/_catalog?last=xxx&n=100>; rel="next"
+1
View File
@@ -39,6 +39,7 @@ export const DELETE: RequestHandler = async ({ url }) => {
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
// Note: orgPath is not used here because imageName already contains the full repo path
const headers: HeadersInit = {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
@@ -80,6 +80,7 @@ async function searchPrivateRegistry(registry: any, term: string, limit: number)
// Try to directly check if an image exists by querying its tags endpoint
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
try {
// Note: orgPath is not used here because imageName already contains the full repo path
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const headers: HeadersInit = {
@@ -104,6 +105,7 @@ async function tryDirectImageLookup(registry: any, imageName: string): Promise<b
// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
// Note: orgPath could be used here to filter results, but search is already term-based
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
const headers: HeadersInit = {
+1
View File
@@ -73,6 +73,7 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
}
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
// Note: orgPath is not used here because imageName already contains the full repo path
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`;
@@ -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 } from 'lucide-svelte';
import { Ship, Cable, ExternalLink, AlertTriangle, Info, Layers } from 'lucide-svelte';
import type { SystemContainerType } from '$lib/types';
interface Props {
@@ -12,6 +12,8 @@
cronExpression: string;
vulnerabilityCriteria: VulnerabilityCriteria;
systemContainer?: SystemContainerType | null;
isComposeContainer?: boolean;
composeStackName?: string;
onenablechange?: (enabled: boolean) => void;
oncronchange?: (cron: string) => void;
oncriteriachange?: (criteria: VulnerabilityCriteria) => void;
@@ -22,6 +24,8 @@
cronExpression = $bindable(),
vulnerabilityCriteria = $bindable(),
systemContainer = null,
isComposeContainer = false,
composeStackName = '',
onenablechange,
oncronchange,
oncriteriachange
@@ -93,6 +97,20 @@
/>
</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}
@@ -114,6 +114,9 @@
autoUpdateEnabled: boolean;
autoUpdateCronExpression: string;
vulnerabilityCriteria: VulnerabilityCriteria;
// Compose stack info
isComposeContainer?: boolean;
composeStackName?: string;
// Config sets
configSets: ConfigSet[];
selectedConfigSetId: string;
@@ -170,6 +173,8 @@
autoUpdateEnabled = $bindable(),
autoUpdateCronExpression = $bindable(),
vulnerabilityCriteria = $bindable(),
isComposeContainer = false,
composeStackName = '',
configSets,
selectedConfigSetId = $bindable(),
errors = $bindable(),
@@ -1273,6 +1278,8 @@
bind:cronExpression={autoUpdateCronExpression}
bind:vulnerabilityCriteria={vulnerabilityCriteria}
systemContainer={detectSystemContainer(image)}
{isComposeContainer}
{composeStackName}
/>
</div>
</div>
@@ -1004,6 +1004,8 @@
bind:autoUpdateEnabled
bind:autoUpdateCronExpression
bind:vulnerabilityCriteria
{isComposeContainer}
{composeStackName}
{configSets}
bind:selectedConfigSetId
bind:errors
+4 -2
View File
@@ -64,8 +64,10 @@
if (isDockerHub(targetRegistry)) {
return tag;
}
const host = new URL(targetRegistry.url).host;
return `${host}/${tag}`;
// Include both host and path (e.g., registry.example.com/organization)
const url = new URL(targetRegistry.url);
const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '');
return `${hostWithPath}/${tag}`;
});
const isProcessing = $derived(pushStatus === 'pushing');
@@ -80,16 +80,20 @@
const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`;
if (sourceRegistry && !isDockerHub(sourceRegistry)) {
const urlObj = new URL(sourceRegistry.url);
return `${urlObj.host}/${imageWithTag}`;
// Include both host and path (e.g., registry.example.com/organization)
const hostWithPath = urlObj.host + (urlObj.pathname !== '/' ? urlObj.pathname.replace(/\/$/, '') : '');
return `${hostWithPath}/${imageWithTag}`;
}
return imageWithTag;
});
const targetImageName = $derived(() => {
if (!targetRegistryId || !targetRegistry) return customTag || 'image:latest';
const host = new URL(targetRegistry.url).host;
// Include both host and path (e.g., registry.example.com/organization)
const url = new URL(targetRegistry.url);
const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '');
const tag = customTag ? (customTag.includes(':') ? customTag : customTag + ':latest') : 'image:latest';
return `${host}/${tag}`;
return `${hostWithPath}/${tag}`;
});
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || pushStatus === 'pushing');