This commit is contained in:
jarek committed 2026-04-03 11:51:42 +02:00
1 parent e9a9f0ca25
commit 0bb10cabb9
42 files changed
+7147 -239

No files matched your search

@@ -14,7 +14,7 @@ import {
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 { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
@@ -156,8 +156,9 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
// Skip system containers (Dockhand, Hawser)
const systemType = isSystemContainer(imageName);
if (systemType) {
sendData({
type: 'progress',
containerId,
@@ -166,7 +167,7 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update Dockhand itself`
message: `Skipping ${containerName} - cannot update ${systemType} container`
});
skippedCount++;
continue;
@@ -331,9 +332,11 @@ export const POST: RequestHandler = async (event) => {
}
}
// Collect vulnerabilities from all scanners (cap at 100)
// 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,
@@ -345,11 +348,11 @@ export const POST: RequestHandler = async (event) => {
scanner: v.scanner
}));
// Build scan message from individual results
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
// 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({
@@ -102,7 +102,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate).length;
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
+4 -1
View File
@@ -119,7 +119,10 @@ export const POST: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret
webhookSecret: webhookSecret,
buildOnDeploy: data.buildOnDeploy ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
// Create stack_sources entry so the stack appears in the list immediately
+4 -1
View File
@@ -72,7 +72,10 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule,
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret
webhookSecret: data.webhookSecret,
buildOnDeploy: data.buildOnDeploy,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
// If stack name changed, update related records
+30 -24
View File
@@ -10,6 +10,14 @@ function isDockerHub(url: string): boolean {
lower.includes('registry.hub.docker.com');
}
// Manifest types in priority order: single-platform first, then multi-arch
const MANIFEST_TYPES = [
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.index.v1+json'
];
export const DELETE: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
@@ -39,43 +47,41 @@ 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'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
// Step 1: Get the manifest digest
// Step 1: Resolve manifest digest. Try each type individually because
// the registry may only serve certain types, and DELETE requires the
// Accept header to match the stored manifest type.
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
const headResponse = await fetch(manifestUrl, {
method: 'HEAD',
headers
});
let digest: string | null = null;
let matchedType: string | null = null;
if (!headResponse.ok) {
for (const mediaType of MANIFEST_TYPES) {
const headers: HeadersInit = { 'Accept': mediaType };
if (authHeader) headers['Authorization'] = authHeader;
const headResponse = await fetch(manifestUrl, { method: 'HEAD', headers });
if (headResponse.ok) {
digest = headResponse.headers.get('Docker-Content-Digest');
matchedType = mediaType;
break;
}
if (headResponse.status === 401) {
return json({ error: 'Authentication failed' }, { status: 401 });
}
if (headResponse.status === 404) {
return json({ error: 'Image or tag not found' }, { status: 404 });
}
return json({ error: `Failed to get manifest: ${headResponse.status}` }, { status: headResponse.status });
}
const digest = headResponse.headers.get('Docker-Content-Digest');
if (!digest) {
return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 });
if (!digest || !matchedType) {
return json({ error: 'Image or tag not found' }, { status: 404 });
}
// Step 2: Delete the manifest by digest
// Step 2: Delete the manifest by digest using the matched type
const deleteHeaders: HeadersInit = { 'Accept': matchedType };
if (authHeader) deleteHeaders['Authorization'] = authHeader;
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
const deleteResponse = await fetch(deleteUrl, {
method: 'DELETE',
headers
headers: deleteHeaders
});
if (!deleteResponse.ok) {
+3 -3
View File
@@ -4,7 +4,7 @@ import {
checkScannerAvailability,
getScannerVersions,
checkScannerUpdates,
cleanupScannerVolumes,
cleanupScannerCache,
getGlobalScannerDefaults,
type ScannerType
} from '$lib/server/scanner';
@@ -195,8 +195,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
}
}
// Also cleanup scanner database volumes
await cleanupScannerVolumes(parsedEnvId);
// Also cleanup scanner database cache (volumes + bind mount dirs)
await cleanupScannerCache(parsedEnvId);
return json({
success: true,
+40
View File
@@ -0,0 +1,40 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { cleanupScannerCache } from '$lib/server/scanner';
import { authorize } from '$lib/server/authorize';
import { getEnvironments } from '$lib/server/db';
export const DELETE: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const result = await cleanupScannerCache();
// Clean remote environment volumes
const skippedEnvs: string[] = [];
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
result.volumes.push(...envResult.volumes);
} catch {
skippedEnvs.push(env.name);
}
}
return json({
success: true,
removedVolumes: result.volumes,
removedDirs: result.dirs,
skippedEnvironments: skippedEnvs
});
} catch (error) {
console.error('Failed to clear scanner cache:', error);
return json({ error: 'Failed to clear scanner cache' }, { status: 500 });
}
};
@@ -0,0 +1,80 @@
import { json } from '@sveltejs/kit';
import { deployStack, requireComposeFile, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !(await auth.can('stacks', 'start', envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
const body = await request.json();
const { pull, build, forceRecreate } = body as {
pull?: boolean;
build?: boolean;
forceRecreate?: boolean;
};
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
send('progress', { status: 'Reading compose file...' });
const composeResult = await requireComposeFile(stackName, envIdNum);
if (!composeResult.success) {
send('result', {
success: false,
error: composeResult.needsFileLocation
? 'Stack compose file location not configured'
: composeResult.error || 'Compose file not found'
});
return;
}
send('progress', { status: 'Deploying stack...' });
const result = await deployStack({
name: stackName,
compose: composeResult.content!,
envId: envIdNum,
pullPolicy: pull ? 'always' : undefined,
build,
forceRecreate,
composePath: composeResult.composePath,
envPath: composeResult.envPath
});
// Audit log
await auditStack(event, 'deploy', stackName, envIdNum, {
pull, build, forceRecreate
});
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
send('result', { success: false, error: error.message });
return;
}
console.error('Error deploying compose stack:', error);
send('result', { success: false, error: 'Failed to deploy compose stack' });
}
}, event.request);
};
@@ -22,10 +22,12 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
const mode = url.searchParams.get('mode') === 'recreate' ? 'recreate' : 'restart';
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await restartStack(stackName, envIdNum);
const result = await restartStack(stackName, envIdNum, mode);
// Audit log
await auditStack(event, 'restart', stackName, envIdNum);