v1.0.23
This commit is contained in:
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user