v1.0.23
This commit is contained in:
1 parent
e9a9f0ca25
commit
0bb10cabb9
42 files changed
+7147
-239
No files matched your search
+52
-1
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user