Files
dockhand/src/routes/api/containers/batch-update-stream/+server.ts
T
2026-04-26 08:01:32 +02:00

546 lines
16 KiB
TypeScript

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
listContainers,
inspectContainer,
pullImage,
getTempImageTag,
isDigestBasedImage,
getImageIdByTag,
removeTempImage,
tagImage
} from '$lib/server/docker';
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, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
export interface ScanResult {
critical: number;
high: number;
medium: number;
low: number;
negligible?: number;
unknown?: number;
}
export interface ScannerResult extends ScanResult {
scanner: 'grype' | 'trivy';
}
export interface UpdateProgress {
type: 'start' | 'progress' | 'pull_log' | 'scan_start' | 'scan_log' | 'scan_complete' | 'blocked' | 'complete' | 'error';
containerId?: string;
containerName?: string;
step?: 'pulling' | 'scanning' | 'stopping' | 'removing' | 'creating' | 'starting' | 'done' | 'failed' | 'blocked' | 'skipped';
message?: string;
current?: number;
total?: number;
success?: boolean;
error?: string;
summary?: {
total: number;
success: number;
failed: number;
blocked: number;
skipped: number;
};
// Pull log specific fields
pullStatus?: string;
pullId?: string;
pullProgress?: string;
// Scan specific fields
scanResult?: ScanResult;
scannerResults?: ScannerResult[];
blockReason?: string;
scanner?: string;
vulnerabilities?: Array<{
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
link?: string;
scanner: string;
}>;
}
/**
* Batch update containers with streaming progress.
* Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria }
*/
export const POST: RequestHandler = async (event) => {
const { url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need create permission to recreate containers
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
let body: { containerIds: string[]; vulnerabilityCriteria?: VulnerabilityCriteria };
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { containerIds, vulnerabilityCriteria = 'never' } = body;
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
return json({ error: 'containerIds array is required' }, { status: 400 });
}
// Job pattern: create job, run in background, return jobId immediately
const job = createJob();
const sendData = (data: UpdateProgress) => {
appendLine(job, { data });
};
(async () => {
let successCount = 0;
let failCount = 0;
let blockedCount = 0;
let skippedCount = 0;
// Get scanner settings for this environment
const scannerSettings = await getScannerSettings(envIdNum);
// Scan if scanning is enabled (scanner !== 'none')
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
const shouldScan = scannerSettings.scanner !== 'none';
// Send start event
sendData({
type: 'start',
total: containerIds.length,
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
});
// Process containers sequentially
for (let i = 0; i < containerIds.length; i++) {
const containerId = containerIds[i];
let containerName = 'unknown';
try {
// Find container
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
if (!container) {
sendData({
type: 'progress',
containerId,
containerName: 'unknown',
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Container not found'
});
failCount++;
continue;
}
containerName = container.name;
// Get full container config
const inspectData = await inspectContainer(containerId, envIdNum) as any;
const config = inspectData.Config;
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Skip system containers (Dockhand, Hawser)
const systemType = isSystemContainer(imageName);
if (systemType) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update ${systemType} container`
});
skippedCount++;
continue;
}
// Skip containers with dockhand.update=false label
if (isUpdateDisabledByLabel(config.Labels)) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - dockhand.update=false label`
});
skippedCount++;
continue;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageName)) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - image pinned to specific digest`
});
skippedCount++;
continue;
}
// Step 1: Pull latest image
sendData({
type: 'progress',
containerId,
containerName,
step: 'pulling',
current: i + 1,
total: containerIds.length,
message: `Pulling ${imageName}...`
});
try {
await pullImage(imageName, (data: any) => {
if (data.status) {
sendData({
type: 'pull_log',
containerId,
containerName,
pullStatus: data.status,
pullId: data.id,
pullProgress: data.progress
});
}
}, envIdNum);
} catch (pullError: any) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Pull failed: ${pullError.message}`
});
failCount++;
continue;
}
// SAFE-PULL FLOW with vulnerability scanning
if (shouldScan && !isDigestBasedImage(imageName)) {
const tempTag = getTempImageTag(imageName);
// Get new image ID
const newImageId = await getImageIdByTag(imageName, envIdNum);
if (!newImageId) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Failed to get new image ID after pull'
});
failCount++;
continue;
}
// Restore original tag to old image (safety)
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
try {
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
} catch {
// Ignore - old image might have been removed
}
// Tag new image with temp suffix
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
// Step 2: Scan temp image
sendData({
type: 'scan_start',
containerId,
containerName,
step: 'scanning',
current: i + 1,
total: containerIds.length,
message: `Scanning ${imageName} for vulnerabilities...`
});
let scanBlocked = false;
let blockReason = '';
let individualScannerResults: ScannerResult[] = [];
try {
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
if (progress.output || progress.message) {
sendData({
type: 'scan_log',
containerId,
containerName,
scanner: progress.scanner,
message: progress.output || progress.message
});
}
});
if (scanResults.length > 0) {
// Build individual scanner results (used by frontend)
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
high: result.summary.high,
medium: result.summary.medium,
low: result.summary.low,
negligible: result.summary.negligible,
unknown: result.summary.unknown
}));
// Save scan results
for (const result of scanResults) {
try {
await saveVulnerabilityScan({
environmentId: envIdNum,
imageId: newImageId,
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
});
} catch { /* ignore save errors */ }
}
// Check if blocked (combineScanSummaries uses Math.max for security check)
const combinedForBlockCheck = combineScanSummaries(scanResults);
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, combinedForBlockCheck, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
}
}
// Collect vulnerabilities from all scanners (sort by severity, cap at 100)
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, negligible: 4, unknown: 5 };
const vulnerabilities = scanResults
.flatMap(r => r.vulnerabilities || [])
.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9))
.slice(0, 100)
.map(v => ({
id: v.id,
severity: v.severity,
package: v.package,
version: v.version,
fixedVersion: v.fixedVersion,
link: v.link,
scanner: v.scanner
}));
// Derive combined totals from the displayed (sliced) array so summary matches the table
const totalCritical = vulnerabilities.filter(v => v.severity === 'critical').length;
const totalHigh = vulnerabilities.filter(v => v.severity === 'high').length;
const totalMedium = vulnerabilities.filter(v => v.severity === 'medium').length;
const totalLow = vulnerabilities.filter(v => v.severity === 'low').length;
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
sendData({
type: 'scan_complete',
containerId,
containerName,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: hasVulns
? `Scan complete: ${totalCritical} critical, ${totalHigh} high, ${totalMedium} medium, ${totalLow} low`
: 'Scan complete: no vulnerabilities found'
});
} catch (scanErr: any) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Scan failed: ${scanErr.message}`
});
// Clean up temp image on scan failure
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
failCount++;
continue;
}
if (scanBlocked) {
// BLOCKED - Remove temp image and skip this container
sendData({
type: 'blocked',
containerId,
containerName,
step: 'blocked',
current: i + 1,
total: containerIds.length,
success: false,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
});
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
blockedCount++;
continue;
}
// APPROVED - Re-tag to original
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
try {
await removeTempImage(tempTag, envIdNum);
} catch { /* ignore cleanup errors */ }
}
// Progress logging function for shared functions
const logProgress = (message: string) => {
sendData({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message
});
};
let newContainerId = containerId;
sendData({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message: `Recreating ${containerName}...`
});
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
if (recreateResult.success) {
const updatedContainers = await listContainers(true, envIdNum);
const updatedContainer = updatedContainers.find(c => c.name === containerName);
if (updatedContainer) {
newContainerId = updatedContainer.id;
}
}
if (!recreateResult.success) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: recreateResult.error || 'Container recreation failed'
});
failCount++;
continue;
}
// Audit log
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
// Done with this container - use original containerId for UI consistency
sendData({
type: 'progress',
containerId,
containerName,
step: 'done',
current: i + 1,
total: containerIds.length,
success: true,
message: `${containerName} updated successfully`
});
successCount++;
// Clear pending update indicator from database
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
// Ignore errors - record may not exist
});
}
} catch (error: any) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: error.message
});
failCount++;
}
}
// Send complete event
const completeData: UpdateProgress = {
type: 'complete',
summary: {
total: containerIds.length,
success: successCount,
failed: failCount,
blocked: blockedCount,
skipped: skippedCount
},
message: skippedCount > 0 || blockedCount > 0
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
: `Updated ${successCount} of ${containerIds.length} containers`
};
sendData(completeData);
completeJob(job, completeData);
})().catch((err) => {
failJob(job, err instanceof Error ? err.message : String(err));
});
return json({ jobId: job.id });
};