mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 13:18:56 +00:00
549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
import { json } from '@sveltejs/kit';
|
|
import type { RequestHandler } from './$types';
|
|
import { authorize } from '$lib/server/authorize';
|
|
import {
|
|
listContainers,
|
|
inspectContainer,
|
|
stopContainer,
|
|
removeContainer,
|
|
createContainer,
|
|
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, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
let controllerClosed = false;
|
|
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const safeEnqueue = (data: UpdateProgress) => {
|
|
if (!controllerClosed) {
|
|
try {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
} catch {
|
|
controllerClosed = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
|
|
keepaliveInterval = setInterval(() => {
|
|
if (controllerClosed) return;
|
|
try {
|
|
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
|
} catch {
|
|
controllerClosed = true;
|
|
}
|
|
}, 5000);
|
|
|
|
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
|
|
safeEnqueue({
|
|
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) {
|
|
safeEnqueue({
|
|
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 wasRunning = inspectData.State.Running;
|
|
const config = inspectData.Config;
|
|
const hostConfig = inspectData.HostConfig;
|
|
const imageName = config.Image;
|
|
const currentImageId = inspectData.Image;
|
|
|
|
// Skip Dockhand container - cannot update itself
|
|
if (isDockhandContainer(imageName)) {
|
|
safeEnqueue({
|
|
type: 'progress',
|
|
containerId,
|
|
containerName,
|
|
step: 'skipped',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
success: true,
|
|
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
|
});
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Step 1: Pull latest image
|
|
safeEnqueue({
|
|
type: 'progress',
|
|
containerId,
|
|
containerName,
|
|
step: 'pulling',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
message: `Pulling ${imageName}...`
|
|
});
|
|
|
|
try {
|
|
await pullImage(imageName, (data: any) => {
|
|
// Send pull progress as log entries
|
|
if (data.status) {
|
|
safeEnqueue({
|
|
type: 'pull_log',
|
|
containerId,
|
|
containerName,
|
|
pullStatus: data.status,
|
|
pullId: data.id,
|
|
pullProgress: data.progress
|
|
});
|
|
}
|
|
}, envIdNum);
|
|
} catch (pullError: any) {
|
|
safeEnqueue({
|
|
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) {
|
|
safeEnqueue({
|
|
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
|
|
safeEnqueue({
|
|
type: 'scan_start',
|
|
containerId,
|
|
containerName,
|
|
step: 'scanning',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
message: `Scanning ${imageName} for vulnerabilities...`
|
|
});
|
|
|
|
let scanBlocked = false;
|
|
let blockReason = '';
|
|
let finalScanResult: ScanResult | undefined;
|
|
let individualScannerResults: ScannerResult[] = [];
|
|
|
|
try {
|
|
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
|
|
if (progress.message) {
|
|
safeEnqueue({
|
|
type: 'scan_log',
|
|
containerId,
|
|
containerName,
|
|
scanner: progress.scanner,
|
|
message: progress.message
|
|
});
|
|
}
|
|
});
|
|
|
|
if (scanResults.length > 0) {
|
|
const scanSummary = combineScanSummaries(scanResults);
|
|
finalScanResult = {
|
|
critical: scanSummary.critical,
|
|
high: scanSummary.high,
|
|
medium: scanSummary.medium,
|
|
low: scanSummary.low,
|
|
negligible: scanSummary.negligible,
|
|
unknown: scanSummary.unknown
|
|
};
|
|
|
|
// Build individual scanner results
|
|
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
|
|
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
|
if (blocked) {
|
|
scanBlocked = true;
|
|
blockReason = reason;
|
|
}
|
|
}
|
|
|
|
safeEnqueue({
|
|
type: 'scan_complete',
|
|
containerId,
|
|
containerName,
|
|
scanResult: finalScanResult,
|
|
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
|
message: finalScanResult
|
|
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
|
: 'Scan complete: no vulnerabilities found'
|
|
});
|
|
|
|
} catch (scanErr: any) {
|
|
safeEnqueue({
|
|
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
|
|
safeEnqueue({
|
|
type: 'blocked',
|
|
containerId,
|
|
containerName,
|
|
step: 'blocked',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
success: false,
|
|
scanResult: finalScanResult,
|
|
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 */ }
|
|
}
|
|
|
|
// Step 3: Stop container if running
|
|
if (wasRunning) {
|
|
safeEnqueue({
|
|
type: 'progress',
|
|
containerId,
|
|
containerName,
|
|
step: 'stopping',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
message: `Stopping ${containerName}...`
|
|
});
|
|
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);
|
|
|
|
// 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 || '' };
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
safeEnqueue({
|
|
type: 'progress',
|
|
containerId,
|
|
containerName,
|
|
step: 'starting',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
message: `Starting ${containerName}...`
|
|
});
|
|
await newContainer.start();
|
|
}
|
|
|
|
// Audit log
|
|
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
|
|
|
|
// Done with this container - use original containerId for UI consistency
|
|
safeEnqueue({
|
|
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) {
|
|
safeEnqueue({
|
|
type: 'progress',
|
|
containerId,
|
|
containerName,
|
|
step: 'failed',
|
|
current: i + 1,
|
|
total: containerIds.length,
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
// Send complete event
|
|
safeEnqueue({
|
|
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`
|
|
});
|
|
|
|
clearInterval(keepaliveInterval);
|
|
controller.close();
|
|
},
|
|
cancel() {
|
|
controllerClosed = true;
|
|
if (keepaliveInterval) {
|
|
clearInterval(keepaliveInterval);
|
|
}
|
|
}
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
}
|
|
});
|
|
};
|