1.0.27
This commit is contained in:
38 files changed
+1115
-274
No files matched your search
@@ -3,6 +3,7 @@ import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, D
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import { isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
@@ -34,7 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
try {
|
||||
const containers = await listContainers(all, envIdNum);
|
||||
return json(containers);
|
||||
// Filter out containers with dockhand.hidden=true label
|
||||
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
|
||||
return json(visible);
|
||||
} catch (error: any) {
|
||||
// Return 404 for missing environment so frontend can clear stale localStorage
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
removeContainer,
|
||||
getContainerLogs
|
||||
} from '$lib/server/docker';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { getStackComposeFile } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
@@ -34,10 +35,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
|
||||
// Mask secret env vars for containers belonging to a Compose stack
|
||||
// Mask secret env vars for containers belonging to a Compose stack.
|
||||
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
|
||||
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
|
||||
if (stackName && Array.isArray(details.Config?.Env)) {
|
||||
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
|
||||
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
|
||||
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
|
||||
if (secretKeys.size > 0) {
|
||||
details.Config.Env = details.Config.Env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { getSecretKeyNames } from '$lib/server/db';
|
||||
import { getSecretKeysToMask } from '$lib/server/db';
|
||||
import { getStackComposeFile } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
@@ -22,10 +23,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
try {
|
||||
const containerData = await inspectContainer(params.id, envIdNum);
|
||||
|
||||
// Mask secret env vars for containers belonging to a Compose stack
|
||||
// Mask secret env vars for containers belonging to a Compose stack.
|
||||
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
|
||||
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
|
||||
if (stackName && Array.isArray(containerData.Config?.Env)) {
|
||||
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
|
||||
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
|
||||
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
|
||||
if (secretKeys.size > 0) {
|
||||
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
|
||||
@@ -15,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, 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';
|
||||
|
||||
@@ -173,6 +174,22 @@ export const POST: RequestHandler = async (event) => {
|
||||
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({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||
|
||||
export interface BatchUpdateResult {
|
||||
containerId: string;
|
||||
@@ -62,6 +63,17 @@ export const POST: RequestHandler = async (event) => {
|
||||
const imageName = config.Image;
|
||||
const containerName = container.name;
|
||||
|
||||
// Skip containers with dockhand.update=false label
|
||||
if (isUpdateDisabledByLabel(config.Labels)) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName,
|
||||
success: true,
|
||||
error: 'Skipped - dockhand.update=false label'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pull latest image first
|
||||
try {
|
||||
await pullImage(imageName, undefined, envIdNum);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
@@ -16,6 +17,7 @@ export interface UpdateCheckResult {
|
||||
error?: string;
|
||||
isLocalImage?: boolean;
|
||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
||||
updateDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
@@ -74,7 +77,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
newDigest: result.registryDigest,
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage,
|
||||
systemContainer: isSystemContainer(imageName) || null
|
||||
systemContainer: isSystemContainer(imageName) || null,
|
||||
updateDisabled
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -102,12 +106,12 @@ 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 && !r.systemContainer).length;
|
||||
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate && !result.systemContainer) {
|
||||
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
|
||||
@@ -115,6 +115,7 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
|
||||
// Delete git stack clone directories before cascade deletes the DB rows
|
||||
const stacks = await getGitStacksByRepositoryId(id);
|
||||
console.log(`[GitStack] Repository "${repository.name}" (id=${id}) deletion affects ${stacks.length} stacks: ${stacks.map(s => s.stackName).join(', ')}`);
|
||||
for (const stack of stacks) {
|
||||
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ export interface GeneralSettings {
|
||||
// Scanner images
|
||||
defaultGrypeImage: string;
|
||||
defaultTrivyImage: string;
|
||||
// Compose template
|
||||
defaultComposeTemplate: string;
|
||||
// Label filter mode
|
||||
labelFilterMode: 'any' | 'all';
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
||||
@@ -105,7 +109,26 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null,
|
||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
|
||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
|
||||
labelFilterMode: 'any' as const,
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- APP_ENV=\${APP_ENV:-production}
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Add more services as needed
|
||||
# networks:
|
||||
# default:
|
||||
# driver: bridge
|
||||
`
|
||||
};
|
||||
|
||||
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
|
||||
@@ -159,7 +182,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
externalStackPaths,
|
||||
primaryStackLocation,
|
||||
defaultGrypeImage,
|
||||
defaultTrivyImage
|
||||
defaultTrivyImage,
|
||||
defaultComposeTemplate,
|
||||
labelFilterMode
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -192,7 +217,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation(),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -227,7 +254,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
externalStackPaths,
|
||||
primaryStackLocation,
|
||||
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -245,7 +274,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -364,6 +393,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
|
||||
await setSetting('default_trivy_image', defaultTrivyImage);
|
||||
}
|
||||
if (defaultComposeTemplate !== undefined && typeof defaultComposeTemplate === 'string') {
|
||||
await setSetting('default_compose_template', defaultComposeTemplate);
|
||||
}
|
||||
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
||||
await setSetting('label_filter_mode', labelFilterMode);
|
||||
}
|
||||
|
||||
// Fetch all settings in parallel for the response
|
||||
const [
|
||||
@@ -398,7 +433,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
externalStackPathsVal,
|
||||
primaryStackLocationVal,
|
||||
defaultGrypeImageVal,
|
||||
defaultTrivyImageVal
|
||||
defaultTrivyImageVal,
|
||||
defaultComposeTemplateVal,
|
||||
labelFilterModeVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -431,7 +468,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation(),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -466,7 +505,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
externalStackPaths: externalStackPathsVal,
|
||||
primaryStackLocation: primaryStackLocationVal,
|
||||
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
Reference in new issue
Block a user