This commit is contained in:
jarek committed 2026-07-25 08:41:01 +02:00
1 parent 9f09d60a83
commit 64b6dcd744
177 files changed
+21182 -1042

No files matched your search

+119 -1
View File
@@ -13,6 +13,9 @@ import {
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getBackupConfigs,
getBackupDestination,
getBackupDestinations,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -26,7 +29,7 @@ import { authorize } from '$lib/server/authorize';
export interface ScheduleInfo {
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
name: string;
entityName: string;
description?: string;
@@ -210,6 +213,121 @@ export const GET: RequestHandler = async ({ cookies }) => {
);
schedules.push(...imagePruneSchedules);
// Get backup schedules (audit #17 — GET must match the SSE stream listing)
const allBackupConfigs = await getBackupConfigs();
const backupSchedules = await Promise.all(
allBackupConfigs
.filter(c => c.schedule)
.map(async (config) => {
const [env, dest, lastExecution, recentExecutions] = await Promise.all([
config.environmentId ? getEnvironment(config.environmentId) : null,
getBackupDestination(config.destinationId),
getLastExecutionForSchedule('backup', config.id),
getRecentExecutionsForSchedule('backup', config.id, 5)
]);
const timezone = config.environmentId ? await getEnvironmentTimezone(config.environmentId) : 'UTC';
const isEnabled = config.enabled ?? false;
const nextRun = isEnabled && config.schedule ? getNextRun(config.schedule, timezone) : null;
return {
id: config.id,
type: 'backup' as const,
name: `Backup: ${config.targetName}`,
entityName: config.targetName,
description: `Back up ${config.type} to ${dest?.name || 'unknown destination'}`,
environmentId: config.environmentId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: config.schedule ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false
};
})
);
schedules.push(...backupSchedules);
// Get repo maintenance schedules (prune + check + verify from destination
// policies). Synthetic ids: dest.id + 100000/200000/300000 (matches the
// stream endpoint and the run/toggle/delete REPO_ID_OFFSET decoding).
const allDestinations = await getBackupDestinations();
for (const dest of allDestinations) {
const policies = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
if (policies.pruneEnabled && policies.pruneSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_prune', dest.id),
getRecentExecutionsForSchedule('repo_prune', dest.id, 5)
]);
const nextRun = getNextRun(policies.pruneSchedule);
const maxUnused = policies.pruneMaxUnused ?? '10';
schedules.push({
id: dest.id + 100000,
type: 'repo_prune' as const,
name: `Prune: ${dest.name}`,
entityName: dest.name,
description: `Prune unused data from ${dest.name} (max unused ${maxUnused}%)`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.pruneSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.checkEnabled && policies.checkSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_check', dest.id),
getRecentExecutionsForSchedule('repo_check', dest.id, 5)
]);
const nextRun = getNextRun(policies.checkSchedule);
schedules.push({
id: dest.id + 200000,
type: 'repo_check' as const,
name: `Check: ${dest.name}`,
entityName: dest.name,
description: `Check integrity of ${dest.name}`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.checkSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.verifyEnabled && policies.verifySchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_verify', dest.id),
getRecentExecutionsForSchedule('repo_verify', dest.id, 5)
]);
const nextRun = getNextRun(policies.verifySchedule);
const subset = policies.verifyDataSubset || '5%';
schedules.push({
id: dest.id + 300000,
type: 'repo_verify' as const,
name: `Verify: ${dest.name}`,
entityName: dest.name,
description: `Verify ${subset} of data in ${dest.name}`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.verifySchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
}
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
@@ -11,7 +11,11 @@ import {
getGitStack,
updateGitStack,
deleteEnvUpdateCheckSettings,
deleteImagePruneSettings
deleteImagePruneSettings,
deleteBackupConfig,
getBackupConfig,
getBackupDestination,
updateBackupDestination
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
@@ -75,6 +79,39 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(scheduleId, 'image_prune');
return json({ success: true });
} else if (type === 'backup') {
const cfg = await getBackupConfig(scheduleId);
if (!cfg) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(cfg.environmentId);
if (envDenied) return envDenied;
await deleteBackupConfig(scheduleId);
unregisterSchedule(scheduleId, 'backup');
return json({ success: true });
} else if (type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify') {
// Repo maintenance schedules are policy-driven; there is no standalone
// row to delete. "Delete" here means disable the policy (audit #18).
// The schedule id is synthetic — decode to the real destination id.
const REPO_ID_OFFSET: Record<string, number> = {
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
};
const destId = scheduleId - REPO_ID_OFFSET[type];
const dest = await getBackupDestination(destId);
if (!dest) {
return json({ error: 'Destination not found' }, { status: 404 });
}
const policies = dest.policies
? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })()
: {};
const enabledKey = type === 'repo_prune' ? 'pruneEnabled'
: type === 'repo_check' ? 'checkEnabled' : 'verifyEnabled';
policies[enabledKey] = false;
await updateBackupDestination(destId, { policies: JSON.stringify(policies) });
unregisterSchedule(destId, type);
return json({ success: true });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be removed' }, { status: 400 });
@@ -11,8 +11,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { getBackupConfig, getBackupDestination } from '$lib/server/db';
import { runScheduledBackup } from '$lib/server/scheduler/tasks/backup';
import { runRepoPrune, runRepoCheck, runRepoVerify } from '$lib/server/scheduler/tasks/repo-maintenance';
import { authorize } from '$lib/server/authorize';
import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db';
import { BACKUPS_ENABLED } from '$lib/server/features';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -28,6 +32,20 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid schedule ID' }, { status: 400 });
}
// BETA GATE: backup-type schedules are unreachable unless FEAT_BACKUPS_ENABLED (see features.ts).
if (!BACKUPS_ENABLED && (type === 'backup' || type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify')) {
return new Response('Not found', { status: 404 });
}
// The schedules stream emits synthetic IDs for the three repo-maintenance
// rows per destination (dest.id + 100000/200000/300000) to keep them unique
// in the UI list. Decode back to the real destination id here (audit #7)
// so getBackupDestination() actually matches. Backup/other types pass through.
const REPO_ID_OFFSET: Record<string, number> = {
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
};
const destId = REPO_ID_OFFSET[type] ? scheduleId - REPO_ID_OFFSET[type] : scheduleId;
// Resolve schedule → environmentId so we can enforce per-env access
// before triggering. System schedules (env null) are gated only by
// the global schedules:run check above.
@@ -52,6 +70,24 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
case 'system_cleanup':
scheduleEnvId = null;
break;
case 'backup': {
// Backup schedules ARE env-scoped (audit #6: these were missing here
// and 400'd before ever reaching the dispatch switch below).
const config = await getBackupConfig(scheduleId);
if (!config) return json({ error: 'Backup config not found' }, { status: 404 });
scheduleEnvId = config.environmentId;
break;
}
case 'repo_prune':
case 'repo_check':
case 'repo_verify': {
// Repo maintenance is destination-scoped, not env-scoped. Validate the
// (decoded) destination exists; access is gated by schedules:run only.
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
scheduleEnvId = null;
break;
}
default:
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
@@ -77,6 +113,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
case 'image_prune':
result = await triggerImagePrune(scheduleId);
break;
case 'backup': {
const config = await getBackupConfig(scheduleId);
if (!config) {
return json({ error: 'Backup config not found' }, { status: 404 });
}
await runScheduledBackup(scheduleId, config.targetName, config.environmentId, 'manual');
result = { success: true };
break;
}
case 'repo_prune': {
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
await runRepoPrune(destId, dest.name, 'manual');
result = { success: true };
break;
}
case 'repo_check': {
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
await runRepoCheck(destId, dest.name, 'manual');
result = { success: true };
break;
}
case 'repo_verify': {
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
const pol = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
await runRepoVerify(destId, dest.name, pol.verifyDataSubset || '5%', 'manual');
result = { success: true };
break;
}
default:
// Unreachable — validated in the resolution switch above.
return json({ error: 'Invalid schedule type' }, { status: 400 });
@@ -5,11 +5,13 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings, getBackupConfig, updateBackupConfig, getBackupDestination, updateBackupDestination } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
import { auditBackup, auditBackupDestination } from '$lib/server/audit';
export const POST: RequestHandler = async ({ params, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('schedules', 'edit');
@@ -110,6 +112,56 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(scheduleId, 'image_prune');
}
return json({ success: true, enabled: newEnabled });
} else if (type === 'backup') {
const config = await getBackupConfig(scheduleId);
if (!config) {
return json({ error: 'Backup config not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(config.environmentId);
if (envDenied) return envDenied;
const newEnabled = !config.enabled;
await updateBackupConfig(scheduleId, { enabled: newEnabled });
if (newEnabled && config.schedule) {
await registerSchedule(scheduleId, 'backup', config.environmentId);
} else {
unregisterSchedule(scheduleId, 'backup');
}
await auditBackup(event, 'update', config.targetName, config.environmentId, { configId: scheduleId, enabled: newEnabled });
return json({ success: true, enabled: newEnabled });
} else if (type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify') {
// Repo maintenance schedules are policy-driven on the destination.
// Toggling flips the corresponding *Enabled flag in the destination's
// policies JSON (audit #18). The schedule id is synthetic — decode it
// back to the real destination id (same offsets as the run endpoint).
const REPO_ID_OFFSET: Record<string, number> = {
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
};
const destId = scheduleId - REPO_ID_OFFSET[type];
const dest = await getBackupDestination(destId);
if (!dest) {
return json({ error: 'Destination not found' }, { status: 404 });
}
const policies = dest.policies
? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })()
: {};
const enabledKey = type === 'repo_prune' ? 'pruneEnabled'
: type === 'repo_check' ? 'checkEnabled' : 'verifyEnabled';
const newEnabled = !policies[enabledKey];
policies[enabledKey] = newEnabled;
await updateBackupDestination(destId, { policies: JSON.stringify(policies) });
// Re-register (or unregister) the repo maintenance job (env-less).
await registerSchedule(destId, type, null);
await auditBackupDestination(event, 'update', destId, dest.name, { policy: enabledKey, enabled: newEnabled });
return json({ success: true, enabled: newEnabled });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
@@ -7,7 +7,7 @@
* - scheduleType: 'container_update' | 'git_stack_sync'
* - scheduleId: number
* - environmentId: number
* - status: 'queued' | 'running' | 'success' | 'failed' | 'skipped'
* - status: 'queued' | 'running' | 'success' | 'warning' | 'failed' | 'skipped'
* - triggeredBy: 'cron' | 'webhook' | 'manual'
* - fromDate: ISO date string
* - toDate: ISO date string
+118
View File
@@ -10,6 +10,9 @@ import {
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getBackupConfigs,
getBackupDestination,
getBackupDestinations,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -180,6 +183,121 @@ async function getSchedulesData(): Promise<ScheduleInfo[]> {
);
schedules.push(...imagePruneSchedules);
// Get backup schedules
const allBackupConfigs = await getBackupConfigs();
const backupSchedules = await Promise.all(
allBackupConfigs
.filter(c => c.schedule)
.map(async (config) => {
const [env, dest, lastExecution, recentExecutions] = await Promise.all([
config.environmentId ? getEnvironment(config.environmentId) : null,
getBackupDestination(config.destinationId),
getLastExecutionForSchedule('backup', config.id),
getRecentExecutionsForSchedule('backup', config.id, 5)
]);
const timezone = config.environmentId ? await getEnvironmentTimezone(config.environmentId) : 'UTC';
const isEnabled = config.enabled ?? false;
const nextRun = isEnabled && config.schedule ? getNextRun(config.schedule, timezone) : null;
return {
id: config.id,
type: 'backup' as const,
name: `Backup: ${config.targetName}`,
entityName: config.targetName,
description: `Back up ${config.type} to ${dest?.name || 'unknown destination'}`,
environmentId: config.environmentId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: config.schedule ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false
};
})
);
schedules.push(...backupSchedules);
// Get repo maintenance schedules (prune + check from destination policies)
const allDestinations = await getBackupDestinations();
for (const dest of allDestinations) {
const policies = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
if (policies.pruneEnabled && policies.pruneSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_prune', dest.id),
getRecentExecutionsForSchedule('repo_prune', dest.id, 5)
]);
const nextRun = getNextRun(policies.pruneSchedule, 'UTC');
const maxUnused = policies.pruneMaxUnused ?? '10';
schedules.push({
id: dest.id + 100000,
type: 'repo_prune' as any,
name: `Prune: ${dest.name}`,
entityName: dest.name,
description: `Prune unused data from ${dest.name} (max unused ${maxUnused}%)`,
maxUnused: String(maxUnused),
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.pruneSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.checkEnabled && policies.checkSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_check', dest.id),
getRecentExecutionsForSchedule('repo_check', dest.id, 5)
]);
const nextRun = getNextRun(policies.checkSchedule, 'UTC');
schedules.push({
id: dest.id + 200000,
type: 'repo_check' as any,
name: `Check: ${dest.name}`,
entityName: dest.name,
description: `Check integrity of ${dest.name}`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.checkSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.verifyEnabled && policies.verifySchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_verify', dest.id),
getRecentExecutionsForSchedule('repo_verify', dest.id, 5)
]);
const nextRun = getNextRun(policies.verifySchedule, 'UTC');
const subset = policies.verifyDataSubset || '5%';
schedules.push({
id: dest.id + 300000,
type: 'repo_verify' as any,
name: `Verify: ${dest.name}`,
entityName: dest.name,
description: `Verify ${subset} of data in ${dest.name}`,
dataSubset: subset,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.verifySchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
}
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(