mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(scheduler): add auto_backup, auto_stop, auto_down, auto_start and delete_after_run one-shot mode (#777)
* feat(scheduler): add auto_backup, auto_stop, auto_down, auto_start actions and delete_after_run one-shot mode Extends the scheduler with four new stack-targeted actions: - auto_backup: backs up stack compose files and .env using the existing FileSystemService.backupStackFiles primitive - auto_stop: runs compose stop (containers preserved) - auto_down: runs compose down (containers removed) - auto_start: runs compose up -d via deployStack (universal start for both stopped and down stacks) Adds delete_after_run boolean column to scheduled_tasks. When enabled, the task self-deletes after its first successful execution; failures keep the task so the user can debug and retry. All four new actions gate at Admiral tier, consistent with restart/snapshot/prune. Migration is idempotent (maybeAddCol). * docs(scheduler): update scheduled-operations doc with new lifecycle actions and delete-after-run Adds the four new actions (Backup Stack Files, Stop Stack, Take Stack Down, Start Stack) to the action table. Documents the delete-after-run one-shot mode with its success-only deletion semantics. Adds the Stack Lifecycle Scheduling section explaining stop-vs-down semantics and the local-execution boundary. Adds three troubleshooting entries: auto-start on a missing compose folder, auto-backup single-slot overwrite by design, and one-shot task disappearing after successful run. Updates the timeline description from four to five lanes. Refreshes screenshots to show the new dialog layout with the Lifecycle lane visible.
This commit is contained in:
@@ -243,3 +243,80 @@ describe('GET /api/scheduled-tasks/:id/runs', () => {
|
||||
expect(res.body).toHaveProperty('runs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/scheduled-tasks - new lifecycle actions', () => {
|
||||
const stackPayload = (action: string) => ({
|
||||
name: `test-${action}`,
|
||||
target_type: 'stack',
|
||||
target_id: 'my-stack',
|
||||
node_id: 1,
|
||||
action,
|
||||
cron_expression: '0 3 * * *',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
for (const action of ['auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
|
||||
it(`creates ${action} task successfully (Admiral)`, async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/scheduled-tasks')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(stackPayload(action));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.action).toBe(action);
|
||||
expect(res.body.target_type).toBe('stack');
|
||||
});
|
||||
|
||||
it(`rejects ${action} with target_type "system"`, async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/scheduled-tasks')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ ...stackPayload(action), target_type: 'system', target_id: null });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/target_type "stack"/);
|
||||
});
|
||||
}
|
||||
|
||||
it('persists delete_after_run flag', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/scheduled-tasks')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ ...stackPayload('auto_backup'), delete_after_run: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.delete_after_run).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults delete_after_run to 0 when not provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/scheduled-tasks')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(stackPayload('auto_stop'));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.delete_after_run).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
it('can toggle delete_after_run via update', async () => {
|
||||
const now = Date.now();
|
||||
const id = DatabaseService.getInstance().createScheduledTask({
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/scheduled-tasks/${id}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ delete_after_run: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.delete_after_run).toBe(1);
|
||||
|
||||
const res2 = await request(app)
|
||||
.put(`/api/scheduled-tasks/${id}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ delete_after_run: false });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.delete_after_run).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* license gating, cron parsing, and error handling.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { ScheduledTask } from '../services/DatabaseService';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,6 +22,10 @@ const {
|
||||
mockIsTrivyAvailable,
|
||||
mockScanAllNodeImages,
|
||||
mockGetStackAutoUpdateSettingsForNode,
|
||||
mockDeleteScheduledTask,
|
||||
mockRunCommand,
|
||||
mockDeployStack,
|
||||
mockBackupStackFiles,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetDueScheduledTasks: vi.fn().mockReturnValue([]),
|
||||
mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1),
|
||||
@@ -56,6 +61,10 @@ const {
|
||||
violations: [],
|
||||
}),
|
||||
mockGetStackAutoUpdateSettingsForNode: vi.fn().mockReturnValue({}),
|
||||
mockDeleteScheduledTask: vi.fn(),
|
||||
mockRunCommand: vi.fn().mockResolvedValue(undefined),
|
||||
mockDeployStack: vi.fn().mockResolvedValue(undefined),
|
||||
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
@@ -75,6 +84,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
|
||||
deleteOldScans: mockDeleteOldScans,
|
||||
getStackAutoUpdateSettingsForNode: mockGetStackAutoUpdateSettingsForNode,
|
||||
deleteScheduledTask: mockDeleteScheduledTask,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -102,6 +112,8 @@ vi.mock('../services/ComposeService', () => ({
|
||||
ComposeService: {
|
||||
getInstance: () => ({
|
||||
updateStack: mockUpdateStack,
|
||||
runCommand: mockRunCommand,
|
||||
deployStack: mockDeployStack,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -112,6 +124,7 @@ vi.mock('../services/FileSystemService', () => ({
|
||||
getStacks: mockGetStacks,
|
||||
getStackContent: mockGetStackContent,
|
||||
getEnvContent: mockGetEnvContent,
|
||||
backupStackFiles: mockBackupStackFiles,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -1357,3 +1370,112 @@ describe('SchedulerService - executeUpdateRemote', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Lifecycle actions (auto_backup, auto_stop, auto_down, auto_start) ───
|
||||
|
||||
function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
return {
|
||||
id: 300,
|
||||
name: `lifecycle-${action}`,
|
||||
action,
|
||||
target_type: 'stack',
|
||||
target_id: 'my-stack',
|
||||
node_id: 1,
|
||||
cron_expression: '0 2 * * *',
|
||||
enabled: 1,
|
||||
created_by: 'admin',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
last_run_at: null,
|
||||
next_run_at: null,
|
||||
last_status: null,
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
delete_after_run: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SchedulerService - lifecycle actions', () => {
|
||||
it('auto_stop calls runCommand with "stop"', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockRunCommand).toHaveBeenCalledWith('my-stack', 'stop');
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
|
||||
});
|
||||
|
||||
it('auto_down calls runCommand with "down"', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_down'));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockRunCommand).toHaveBeenCalledWith('my-stack', 'down');
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
|
||||
});
|
||||
|
||||
it('auto_start calls deployStack', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_start'));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockDeployStack).toHaveBeenCalledWith('my-stack');
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
|
||||
});
|
||||
|
||||
it('auto_backup calls backupStackFiles', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup'));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockBackupStackFiles).toHaveBeenCalledWith('my-stack');
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
|
||||
});
|
||||
|
||||
it('auto_stop records failure when target_id is missing', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop', { target_id: null }));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockRunCommand).not.toHaveBeenCalled();
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
|
||||
});
|
||||
|
||||
it('auto_backup records failure when node_id is missing', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { node_id: null }));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockBackupStackFiles).not.toHaveBeenCalled();
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
|
||||
});
|
||||
|
||||
it('non-admiral paid tier skips lifecycle actions', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('standard');
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeLifecycleTask('auto_stop')]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
|
||||
expect(mockRunCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete_after_run ────────────────────────────────────────────────────
|
||||
|
||||
describe('SchedulerService - delete_after_run', () => {
|
||||
it('deletes task after successful run when delete_after_run is 1', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { delete_after_run: 1 }));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockDeleteScheduledTask).toHaveBeenCalledWith(300);
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
|
||||
});
|
||||
|
||||
it('does not delete task when run fails even if delete_after_run is 1', async () => {
|
||||
mockBackupStackFiles.mockRejectedValueOnce(new Error('disk full'));
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { delete_after_run: 1 }));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockDeleteScheduledTask).not.toHaveBeenCalled();
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
|
||||
});
|
||||
|
||||
it('does not delete task when delete_after_run is 0', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { delete_after_run: 0 }));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockDeleteScheduledTask).not.toHaveBeenCalled();
|
||||
expect(mockUpdateScheduledTask).toHaveBeenCalledWith(300, expect.objectContaining({ last_status: 'success' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,15 @@ import { escapeCsvField } from '../utils/csv';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
|
||||
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan'] as const;
|
||||
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const;
|
||||
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
||||
|
||||
type TargetType = typeof VALID_TARGET_TYPES[number];
|
||||
type ScheduledAction = typeof VALID_ACTIONS[number];
|
||||
|
||||
const STACK_ONLY_ACTIONS = new Set<ScheduledAction>(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']);
|
||||
|
||||
function parseTaskId(req: Request, res: Response): number | null {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) {
|
||||
@@ -35,6 +37,9 @@ function validateActionTarget(action: ScheduledAction, targetType: TargetType):
|
||||
if (action === 'snapshot' && targetType !== 'fleet') return 'Snapshot action requires target_type "fleet".';
|
||||
if (action === 'prune' && targetType !== 'system') return 'Prune action requires target_type "system".';
|
||||
if (action === 'scan' && targetType !== 'system') return 'Scan action requires target_type "system".';
|
||||
if (STACK_ONLY_ACTIONS.has(action) && targetType !== 'stack') {
|
||||
return `${action} action requires target_type "stack".`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' }); return;
|
||||
@@ -122,7 +127,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
|
||||
}
|
||||
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return;
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.' }); return;
|
||||
}
|
||||
if (!requireScheduledTaskTier(action, req, res)) return;
|
||||
|
||||
@@ -169,6 +174,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
|
||||
target_services: target_services ? JSON.stringify(target_services) : null,
|
||||
prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null,
|
||||
delete_after_run: delete_after_run ? 1 : 0,
|
||||
});
|
||||
|
||||
console.log(`[ScheduledTasks] Created task id=${id} action=${action} target=${target_id || 'none'}`);
|
||||
@@ -208,7 +214,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(existing.action, req, res)) return;
|
||||
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
|
||||
|
||||
if (target_type && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type' }); return;
|
||||
@@ -256,6 +262,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
|
||||
if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null;
|
||||
if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null;
|
||||
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
||||
|
||||
const finalCron = cron_expression || existing.cron_expression;
|
||||
const finalEnabled = enabled !== undefined ? enabled : existing.enabled;
|
||||
|
||||
@@ -264,7 +264,7 @@ export interface ScheduledTask {
|
||||
target_type: 'stack' | 'fleet' | 'system';
|
||||
target_id: string | null;
|
||||
node_id: number | null;
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
|
||||
cron_expression: string;
|
||||
enabled: number;
|
||||
created_by: string;
|
||||
@@ -277,6 +277,7 @@ export interface ScheduledTask {
|
||||
prune_targets: string | null;
|
||||
target_services: string | null;
|
||||
prune_label_filter: string | null;
|
||||
delete_after_run?: number;
|
||||
}
|
||||
|
||||
export interface ScheduledTaskRun {
|
||||
@@ -949,6 +950,7 @@ export class DatabaseService {
|
||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
|
||||
|
||||
// Recreate stack_update_status with composite PK (node_id, stack_name).
|
||||
// Original table had stack_name as sole PK which breaks when multiple nodes share stack names.
|
||||
@@ -2471,13 +2473,13 @@ export class DatabaseService {
|
||||
|
||||
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, delete_after_run) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
task.name, task.target_type, task.target_id, task.node_id,
|
||||
task.action, task.cron_expression, task.enabled, task.created_by,
|
||||
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
|
||||
task.last_status, task.last_error, task.prune_targets, task.target_services,
|
||||
task.prune_label_filter
|
||||
task.prune_label_filter, task.delete_after_run ?? 0
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
@@ -2494,6 +2496,7 @@ export class DatabaseService {
|
||||
last_status: updates.last_status, last_error: updates.last_error,
|
||||
prune_targets: updates.prune_targets, target_services: updates.target_services,
|
||||
prune_label_filter: updates.prune_label_filter,
|
||||
delete_after_run: updates.delete_after_run,
|
||||
};
|
||||
|
||||
for (const [col, val] of Object.entries(map)) {
|
||||
|
||||
@@ -289,10 +289,35 @@ export class SchedulerService {
|
||||
scanFailedCount = result.failed;
|
||||
break;
|
||||
}
|
||||
case 'auto_backup':
|
||||
output = await this.executeAutoBackup(task);
|
||||
break;
|
||||
case 'auto_stop':
|
||||
output = await this.executeAutoStop(task);
|
||||
break;
|
||||
case 'auto_down':
|
||||
output = await this.executeAutoDown(task);
|
||||
break;
|
||||
case 'auto_start':
|
||||
output = await this.executeAutoStart(task);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} action completed in ${Date.now() - actionStart}ms`);
|
||||
|
||||
db.updateScheduledTaskRun(runId, {
|
||||
completed_at: Date.now(),
|
||||
status: 'success',
|
||||
output,
|
||||
});
|
||||
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
|
||||
|
||||
if (task.delete_after_run === 1) {
|
||||
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) self-deleting after successful one-shot run`);
|
||||
db.deleteScheduledTask(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRun = this.calculateNextRun(task.cron_expression);
|
||||
db.updateScheduledTask(task.id, {
|
||||
last_run_at: Date.now(),
|
||||
@@ -301,12 +326,7 @@ export class SchedulerService {
|
||||
last_error: null,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
db.updateScheduledTaskRun(runId, {
|
||||
completed_at: Date.now(),
|
||||
status: 'success',
|
||||
output,
|
||||
});
|
||||
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
|
||||
|
||||
if (task.action === 'scan') {
|
||||
const scanLevel: 'info' | 'warning' = scanFailedCount > 0 ? 'warning' : 'info';
|
||||
if (isDebugEnabled()) {
|
||||
@@ -392,6 +412,36 @@ export class SchedulerService {
|
||||
return `Restarted ${filtered.length} container(s) in stack "${task.target_id}"${servicesSuffix}`;
|
||||
}
|
||||
|
||||
private assertStackTarget(task: ScheduledTask, label: string): asserts task is ScheduledTask & { target_id: string; node_id: number } {
|
||||
if (!task.target_id || task.node_id == null) {
|
||||
throw new Error(`${label} requires target_id and node_id`);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAutoBackup(task: ScheduledTask): Promise<string> {
|
||||
this.assertStackTarget(task, 'Auto-backup');
|
||||
await FileSystemService.getInstance(task.node_id).backupStackFiles(task.target_id);
|
||||
return `Backed up stack "${task.target_id}" files`;
|
||||
}
|
||||
|
||||
private async executeAutoStop(task: ScheduledTask): Promise<string> {
|
||||
this.assertStackTarget(task, 'Auto-stop');
|
||||
await ComposeService.getInstance(task.node_id).runCommand(task.target_id, 'stop');
|
||||
return `Stopped stack "${task.target_id}" (containers preserved)`;
|
||||
}
|
||||
|
||||
private async executeAutoDown(task: ScheduledTask): Promise<string> {
|
||||
this.assertStackTarget(task, 'Auto-down');
|
||||
await ComposeService.getInstance(task.node_id).runCommand(task.target_id, 'down');
|
||||
return `Took down stack "${task.target_id}" (containers removed)`;
|
||||
}
|
||||
|
||||
private async executeAutoStart(task: ScheduledTask): Promise<string> {
|
||||
this.assertStackTarget(task, 'Auto-start');
|
||||
await ComposeService.getInstance(task.node_id).deployStack(task.target_id);
|
||||
return `Started stack "${task.target_id}"`;
|
||||
}
|
||||
|
||||
private async executeSnapshot(task: ScheduledTask): Promise<string> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
|
||||
Reference in New Issue
Block a user