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

+52 -1
View File
@@ -2055,6 +2055,9 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
buildOnDeploy: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
@@ -2144,6 +2147,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2174,6 +2180,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2202,6 +2211,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2231,6 +2243,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2260,6 +2275,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2289,6 +2307,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2323,6 +2344,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2352,6 +2376,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2381,6 +2408,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2408,6 +2438,9 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
buildOnDeploy?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
const result = await db.insert(gitStacks).values({
stackName: data.stackName,
@@ -2419,7 +2452,10 @@ export async function createGitStack(data: {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
}
@@ -2436,6 +2472,9 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
@@ -2470,6 +2509,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2497,6 +2539,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2525,6 +2570,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2551,6 +2599,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
+3
View File
@@ -315,6 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
+3
View File
@@ -318,6 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
buildOnDeploy: boolean('build_on_deploy').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
+18 -4
View File
@@ -153,6 +153,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
}
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
@@ -932,8 +937,10 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// Check if there are changes - skip redeploy if no changes and not forced
// Note: For new stacks (first deploy), syncResult.updated will be true
if (!force && !syncResult.updated) {
console.log(`${logPrefix} No changes detected and force=false, skipping redeploy`);
// forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs
const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated;
if (!shouldDeploy) {
console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`);
return {
success: true,
output: 'No changes detected, skipping redeploy',
@@ -943,6 +950,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
const forceRecreate = syncResult.updated;
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
console.log(`${logPrefix} Build on deploy:`, gitStack.buildOnDeploy);
console.log(`${logPrefix} Re-pull images:`, gitStack.repullImages);
console.log(`${logPrefix} Force redeploy setting:`, gitStack.forceRedeploy);
// Deploy using unified function - handles both new and existing stacks
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
@@ -960,7 +970,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate
forceRecreate,
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -1214,7 +1226,9 @@ export async function deployGitStackWithProgress(
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
envFileName // Env file relative to compose dir (for --env-file flag, optional)
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
if (result.success) {
+43 -1
View File
@@ -18,7 +18,7 @@ import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown } from 'node:fs/promises';
import { mkdir, chown, rm } from 'node:fs/promises';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -1147,3 +1147,45 @@ export async function cleanupScannerVolumes(envId?: number): Promise<void> {
console.error('[Scanner] Failed to cleanup scanner volumes:', errorMsg);
}
}
/**
* Clean up all scanner cache storage (volumes + bind mount directories).
* Handles both standard Docker (named volumes) and rootless Docker (bind mounts).
* Next scan after cleanup will re-download a fresh vulnerability database (~200MB).
*/
export async function cleanupScannerCache(envId?: number): Promise<{ volumes: string[]; dirs: string[] }> {
const removedVolumes: string[] = [];
const removedDirs: string[] = [];
// 1. Remove named volumes (standard Docker mode)
for (const volumeName of [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME]) {
try {
await removeVolume(volumeName, true, envId);
removedVolumes.push(volumeName);
const envSuffix = envId ? ` (env ${envId})` : '';
console.log(`[Scanner] Removed volume: ${volumeName}${envSuffix}`);
} catch {
// Volume might not exist, ignore
}
}
// 2. Remove bind mount cache directories (rootless Docker mode, local only)
if (!envId) {
for (const scannerType of ['grype', 'trivy'] as const) {
const cachePath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType);
try {
await rm(cachePath, { recursive: true, force: true });
removedDirs.push(cachePath);
console.log(`[Scanner] Removed cache directory: ${cachePath}`);
} catch {
// Directory might not exist, ignore
}
}
}
if (removedVolumes.length > 0 || removedDirs.length > 0) {
console.log(`[Scanner] Cache cleanup complete: ${removedVolumes.length} volumes, ${removedDirs.length} directories removed`);
}
return { volumes: removedVolumes, dirs: removedDirs };
}
+62 -1
View File
@@ -45,9 +45,11 @@ import {
runScheduleCleanupJob,
runEventCleanupJob,
runVolumeHelperCleanupJob,
runScannerCacheCleanupJob,
SYSTEM_SCHEDULE_CLEANUP_ID,
SYSTEM_EVENT_CLEANUP_ID,
SYSTEM_VOLUME_HELPER_CLEANUP_ID
SYSTEM_VOLUME_HELPER_CLEANUP_ID,
SYSTEM_SCANNER_CLEANUP_ID
} from './tasks/system-cleanup';
// Store all active cron jobs
@@ -57,6 +59,7 @@ const activeJobs: Map<string, Cron> = new Map();
let cleanupJob: Cron | null = null;
let eventCleanupJob: Cron | null = null;
let volumeHelperCleanupJob: Cron | null = null;
let scannerCacheCleanupJob: Cron | null = null;
// Scheduler state
let isRunning = false;
@@ -131,10 +134,35 @@ export async function startScheduler(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
// Register all dynamic schedules from database
await refreshAllSchedules();
@@ -164,6 +192,10 @@ export function stopScheduler(): void {
volumeHelperCleanupJob.stop();
volumeHelperCleanupJob = null;
}
if (scannerCacheCleanupJob) {
scannerCacheCleanupJob.stop();
scannerCacheCleanupJob = null;
}
// Stop all dynamic jobs
for (const [key, job] of activeJobs.entries()) {
@@ -487,6 +519,9 @@ export async function refreshSystemJobs(): Promise<void> {
if (volumeHelperCleanupJob) {
volumeHelperCleanupJob.stop();
}
if (scannerCacheCleanupJob) {
scannerCacheCleanupJob.stop();
}
// Re-create with new timezone
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
@@ -501,9 +536,18 @@ export async function refreshSystemJobs(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
}
// =============================================================================
@@ -637,6 +681,13 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
cleanupExpiredVolumeHelpers
});
return { success: true };
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
runScannerCacheCleanupJob('manual', scannerCleanupFn);
return { success: true };
} else {
return { success: false, error: 'Unknown system job ID' };
}
@@ -694,6 +745,16 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
nextRun: getNextRun('*/30 * * * *')?.toISOString() ?? null,
isSystem: true,
enabled: true
},
{
id: SYSTEM_SCANNER_CLEANUP_ID,
type: 'system_cleanup' as const,
name: 'Scanner cache cleanup',
description: 'Removes scanner vulnerability database cache to reclaim disk space',
cronExpression: '0 3 * * 0',
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
isSystem: true,
enabled: true
}
];
}
@@ -20,6 +20,7 @@ import {
export const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
export const SYSTEM_EVENT_CLEANUP_ID = 2;
export const SYSTEM_VOLUME_HELPER_CLEANUP_ID = 3;
export const SYSTEM_SCANNER_CLEANUP_ID = 4;
/**
* Execute schedule execution cleanup job.
@@ -200,3 +201,66 @@ export async function runVolumeHelperCleanupJob(
});
}
}
/**
* Execute scanner cache cleanup job.
* Removes scanner database volumes and bind mount directories to reclaim disk space.
*/
export async function runScannerCacheCleanupJob(
triggeredBy: ScheduleTrigger = 'cron',
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
): Promise<void> {
const startTime = Date.now();
const execution = await createScheduleExecution({
scheduleType: 'system_cleanup',
scheduleId: SYSTEM_SCANNER_CLEANUP_ID,
environmentId: null,
entityName: 'Scanner cache cleanup',
triggeredBy,
status: 'running'
});
await updateScheduleExecution(execution.id, {
startedAt: new Date().toISOString()
});
const log = async (message: string) => {
console.log(`[Scanner Cache Cleanup] ${message}`);
await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`);
};
try {
await log('Starting scanner cache cleanup');
let result: { volumes: string[]; dirs: string[] };
if (cleanupFn) {
result = await cleanupFn();
} else {
const { cleanupScannerCache } = await import('../../scanner');
result = await cleanupScannerCache();
}
if (result.volumes.length > 0) {
await log(`Removed volumes: ${result.volumes.join(', ')}`);
}
if (result.dirs.length > 0) {
await log(`Removed directories: ${result.dirs.join(', ')}`);
}
await log(`Cleanup complete: ${result.volumes.length} volumes, ${result.dirs.length} directories removed`);
await updateScheduleExecution(execution.id, {
status: 'success',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { removedVolumes: result.volumes, removedDirs: result.dirs }
});
} catch (error: any) {
await log(`Error: ${error.message}`);
await updateScheduleExecution(execution.id, {
status: 'failed',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
errorMessage: error.message
});
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Pure SSE parsing utilities — no server dependencies.
* Can be safely imported in unit tests and client code.
*/
/**
* Check if the client prefers JSON over SSE.
* Returns true when Accept header includes application/json but NOT text/event-stream.
*/
export function prefersJSON(request?: Request): boolean {
const accept = request?.headers.get('accept') || '';
return accept.includes('application/json') && !accept.includes('text/event-stream');
}
/**
* Wrap an SSE Response for JSON-preferring clients.
*
* Consumes the SSE stream using proper event framing (blank-line delimited,
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
* data as a JSON response, or a fallback if no result event was emitted.
*
* Usage:
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
* return buildSSEResponse();
*/
export async function sseToJSON(sseResponse: Response): Promise<Response> {
const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
let dataLines: string[] = [];
let resultData: unknown = null;
const dispatch = () => {
const data = dataLines.join('\n');
const type = eventType || 'message';
eventType = '';
dataLines = [];
if (type === 'result' && data) {
try {
resultData = JSON.parse(data);
} catch {
// keep previous resultData
}
}
};
const parseLine = (rawLine: string) => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.startsWith(':')) return;
if (line === '') { dispatch(); return; }
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
if (field === 'event') eventType = val || 'message';
else if (field === 'data') dataLines.push(val);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) parseLine(line);
}
// Flush remaining bytes and process trailing content
buffer += decoder.decode();
if (buffer) {
for (const line of buffer.split('\n')) parseLine(line);
}
// Final dispatch for servers missing trailing blank line
if (dataLines.length > 0) dispatch();
} catch {
// stream error, return what we have
} finally {
reader.releaseLock();
}
const body = resultData ?? { success: false, error: 'No result' };
return new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' }
});
}
+3 -84
View File
@@ -1,90 +1,9 @@
import { json } from '@sveltejs/kit';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
import { prefersJSON } from '$lib/server/sse-parser';
/**
* Check if the client prefers JSON over SSE.
* Returns true when Accept header includes application/json but NOT text/event-stream.
*/
export function prefersJSON(request?: Request): boolean {
const accept = request?.headers.get('accept') || '';
return accept.includes('application/json') && !accept.includes('text/event-stream');
}
/**
* Wrap an SSE Response for JSON-preferring clients.
*
* Consumes the SSE stream using proper event framing (blank-line delimited,
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
* data as a JSON response, or a fallback if no result event was emitted.
*
* Usage:
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
* return buildSSEResponse();
*/
export async function sseToJSON(sseResponse: Response): Promise<Response> {
const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
let dataLines: string[] = [];
let resultData: unknown = null;
const dispatch = () => {
const data = dataLines.join('\n');
const type = eventType || 'message';
eventType = '';
dataLines = [];
if (type === 'result' && data) {
try {
resultData = JSON.parse(data);
} catch {
// keep previous resultData
}
}
};
const parseLine = (rawLine: string) => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.startsWith(':')) return;
if (line === '') { dispatch(); return; }
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
if (field === 'event') eventType = val || 'message';
else if (field === 'data') dataLines.push(val);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) parseLine(line);
}
// Flush remaining bytes and process trailing content
buffer += decoder.decode();
if (buffer) {
for (const line of buffer.split('\n')) parseLine(line);
}
// Final dispatch for servers missing trailing blank line
if (dataLines.length > 0) dispatch();
} catch {
// stream error, return what we have
} finally {
reader.releaseLock();
}
const body = resultData ?? { success: false, error: 'No result' };
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' }
});
}
// Re-export pure parsing utilities (no server deps) for backward compat
export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
/**
* Job-based response for long-running operations.
+70 -29
View File
@@ -58,6 +58,8 @@ export interface StackOperationResult {
success: boolean;
output?: string;
error?: string;
/** The docker compose command that was executed (for debugging/testing) */
command?: string;
}
/**
@@ -99,6 +101,8 @@ export interface DeployStackOptions {
envId?: number | null;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
@@ -788,6 +792,8 @@ interface ComposeCommandOptions {
stackName: string;
envId?: number | null;
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
/** Working directory for compose execution (for imported stacks) */
@@ -848,7 +854,9 @@ async function executeLocalCompose(
customComposePath?: string,
customEnvPath?: string,
useOverrideFile?: boolean,
serviceName?: string
serviceName?: string,
build?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1040,6 +1048,8 @@ async function executeLocalCompose(
case 'up':
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
args.push(serviceName);
@@ -1067,11 +1077,13 @@ async function executeLocalCompose(
break;
}
const commandStr = args.join(' ');
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} Operation:`, operation);
console.log(`${logPrefix} Command:`, args.join(' '));
console.log(`${logPrefix} Command:`, commandStr);
console.log(`${logPrefix} Working directory:`, stackDir);
console.log(`${logPrefix} Compose file:`, composeFile);
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
@@ -1141,20 +1153,23 @@ async function executeLocalCompose(
return {
success: false,
output: stdout,
error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds`
error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds`,
command: commandStr
};
}
if (code === 0) {
return {
success: true,
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`,
command: commandStr
};
} else {
return {
success: false,
output: stdout,
error: stderr || `docker compose ${operation} exited with code ${code}`
error: stderr || `docker compose ${operation} exited with code ${code}`,
command: commandStr
};
}
} finally {
@@ -1165,7 +1180,8 @@ async function executeLocalCompose(
return {
success: false,
output: '',
error: `Failed to run docker compose ${operation}: ${err.message}`
error: `Failed to run docker compose ${operation}: ${err.message}`,
command: commandStr
};
} finally {
// Cleanup temp override file from host path translation
@@ -1207,7 +1223,9 @@ async function executeComposeViaHawser(
removeVolumes?: boolean,
stackFiles?: Record<string, string>,
serviceName?: string,
composeFileName?: string
composeFileName?: string,
build?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1280,6 +1298,8 @@ async function executeComposeViaHawser(
files, // Files including .env (secrets NOT in .env file)
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
build: build || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
});
@@ -1347,7 +1367,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1369,7 +1389,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
@@ -1431,7 +1453,9 @@ async function executeComposeCommand(
removeVolumes,
hawserStackFiles,
serviceName,
composeFileName
composeFileName,
build,
pullPolicy
);
}
@@ -1462,7 +1486,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
@@ -1483,7 +1509,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
}
@@ -1747,7 +1775,7 @@ export interface RequireComposeResult {
* - envPath: Path to the .env file (Docker Compose reads non-secrets from it)
* - needsFileLocation: true if stack needs user to specify file paths
*/
async function requireComposeFile(
export async function requireComposeFile(
stackName: string,
envId?: number | null,
composeConfigPath?: string
@@ -1877,12 +1905,19 @@ export async function stopStack(
}
/**
* Restart a stack using docker compose restart
* Falls back to individual container restart for stacks without compose files
* Restart a stack using docker compose restart or stop+up (recreate mode).
*
* mode='restart' (default): Uses 'docker compose restart' — fast, in-place restart
* that preserves container IDs but won't fix stale network_mode references.
* mode='recreate': Uses 'docker compose stop' then 'docker compose up -d' —
* recreates containers, fixing network_mode: service:<container> dependencies.
*
* Falls back to individual container restart for stacks without compose files.
*/
export async function restartStack(
stackName: string,
envId?: number | null
envId?: number | null,
mode: 'restart' | 'recreate' = 'restart'
): Promise<StackOperationResult> {
const result = await requireComposeFile(stackName, envId);
@@ -1891,13 +1926,17 @@ export async function restartStack(
return withContainerFallback(stackName, envId, 'restart');
}
const composeResult = await executeComposeCommand(
'restart',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.nonSecretVars,
result.secretVars
);
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
let composeResult: StackOperationResult;
if (mode === 'recreate') {
// Stop first, then bring up with --force-recreate to ensure new container IDs
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars);
composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars);
} else {
composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars);
}
// Restart any dynamically-spawned child containers not in the compose file
await cleanupOrphanStackContainers(stackName, envId, 'restart');
@@ -2133,7 +2172,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2206,12 +2245,12 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`);
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
// Copy source to stack directory
// Copy git source files to stack directory (overlay, not replace).
// Do NOT rmSync first — relative volume mounts (e.g., ./data) live here
// and would be destroyed, causing data loss (#831).
console.log(`${logPrefix} Copying source directory to stack directory...`);
if (existsSync(workingDir)) {
rmSync(workingDir, { recursive: true, force: true });
}
cpSync(sourceDir, workingDir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
cpSync(sourceDir, workingDir, { recursive: true, force: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
@@ -2275,6 +2314,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
stackName: name,
envId,
forceRecreate,
build,
pullPolicy,
stackFiles,
workingDir,
composePath: actualComposePath,