diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 3c9a3321..563d5667 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index a8ffde25..93d6966e 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -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 { + 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' })); + }); +}); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 4b262682..1566860e 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -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(['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; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 176cd9d5..6f0587c2 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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)) { diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 3164f929..4a427ffc 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 620b07e8..f398b402 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -1,6 +1,6 @@ --- title: Scheduled Operations -description: Automate recurring Docker operations like stack restarts, fleet snapshots, and system prunes on a cron schedule. +description: Automate recurring Docker operations like stack restarts, lifecycle management, fleet snapshots, and system prunes on a cron schedule. --- @@ -11,7 +11,7 @@ description: Automate recurring Docker operations like stack restarts, fleet sna Scheduled Operations lets you automate recurring maintenance tasks across your infrastructure. Define a cron schedule, choose an action, and Sencho handles the rest, including a full execution history log so you always know what ran and when. -The view opens on a **Timeline** that plots the next 24 hours of scheduled work across four lanes (Restart, Update, Scan, Prune) so you can see, at a glance, what is about to fire and when. Toggle to **All tasks** for the full CRUD table. +The view opens on a **Timeline** that plots the next 24 hours of scheduled work across five lanes (Restart, Update, Scan, Prune, Lifecycle) so you can see, at a glance, what is about to fire and when. Toggle to **All tasks** for the full CRUD table. Schedules timeline showing the next 24 hours across four lanes with a cyan now rail @@ -22,7 +22,7 @@ The view opens on a **Timeline** that plots the next 24 hours of scheduled work The timeline is the default view. It shows: - A hero with the current 24-hour window as a date range, and the **next firing** on the right (time, task name, and relative countdown). -- Four color-coded lanes: **Restart** (cyan), **Update** (green), **Scan** (purple), and **Prune** (amber). Snapshot tasks share the Prune lane. +- Five color-coded lanes: **Restart** (cyan), **Update** (green), **Scan** (purple), **Prune** (amber), and **Lifecycle** (blue). Snapshot tasks share the Prune lane; stop, down, start, and backup tasks share the Lifecycle lane. - One pill per firing within the window, positioned proportionally to the task's next run time. Click any pill to open that task's execution history. - A vertical cyan **now rail** at the left edge, and six mono time ticks along the bottom axis. @@ -39,6 +39,10 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard | **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files | | **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label | | **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed, see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. | +| **Backup Stack Files** | A specific stack on a specific node | Backs up the stack's compose file and `.env` to `/backups//`. The most recent backup per stack is kept; each run overwrites the previous one. | +| **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop` — containers are stopped but preserved. Use this for off-hours power saving when you want a fast start later. | +| **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down` — containers are removed. Use this to fully release resources when the stack is not needed for an extended period. | +| **Start Stack** | A specific stack on a specific node | Runs `docker compose up -d`. Works whether the stack was stopped or taken down: if containers exist they are started; if not, they are created and started from the compose file. | ## Creating a Scheduled Task @@ -46,7 +50,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard 2. Click **New Schedule**. 3. Fill in the form: - **Name**: A descriptive label (e.g. "Nightly staging restart"). - - **Action**: Choose Restart Stack, Auto-update Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection. + - **Action**: Choose from the full action list. The form fields below change based on your selection. - **Node**: (Restart Stack, Auto-update Stack, and Vulnerability Scan) Select the node to run against. For stack actions it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned. - **Stack**: (Restart Stack and Auto-update Stack) Select the stack to target. Becomes available after choosing a node. - **Services**: (Restart Stack only) Optionally select specific services within the stack to restart. Leave empty to restart all services. @@ -54,6 +58,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard - **Label Filter**: (System Prune only) Optionally filter prune operations to resources matching a specific Docker label (e.g. `com.docker.compose.project=mystack`). - **Cron Expression**: Standard 5-field cron format. A human-readable preview appears below the input. - **Enabled**: Toggle the task on or off. + - **Delete after successful run**: When checked, the task automatically removes itself after its first successful execution. This turns the task into a one-time operation. Failures keep the task so you can inspect the error, adjust the target if needed, and trigger a retry with **Run Now** or by re-enabling the schedule. 4. Click **Create**. @@ -101,6 +106,30 @@ Enter a label in `key=value` format (e.g. `com.docker.compose.project=mystack`). Label filter input for scoping prune operations to specific Docker labels +### Stack Lifecycle Scheduling + +The Stop Stack, Take Stack Down, Start Stack, and Backup Stack Files actions let you schedule lifecycle events for individual stacks on a per-node basis. + +A common pattern for development or staging stacks is: +- A **Stop Stack** or **Take Stack Down** task scheduled for the end of the working day (e.g. `0 19 * * 1-5` — 7 PM on weekdays). +- A **Start Stack** task scheduled for the start of the working day (e.g. `0 8 * * 1-5` — 8 AM on weekdays). + +**Stop vs Down:** Use Stop Stack when you want containers ready to resume quickly; Docker keeps the container filesystem in place and `Start Stack` simply restarts the existing containers. Use Take Stack Down when you want to fully release resources; `Start Stack` recreates the containers from the compose file on the next run. + +**Backup Stack Files** keeps only the most recent backup per stack under `/backups//`. For a point-in-time archive across all nodes, use a [Fleet Snapshot](/features/fleet-backups) schedule instead. + +All four actions run against the local Sencho instance only. To schedule lifecycle operations on a remote node, manage the schedule from within that node's own Sencho UI. + +### Delete after Successful Run + +When **Delete after successful run** is enabled, the task removes itself from the schedule after its first successful execution. The entire task record, including run history, is removed. Use this for one-time preparatory or clean-up tasks: pre-scaling a stack before a deployment, a one-off backup before a config change, or stopping a stack once a migration is complete. + +If the run fails, the task stays in the schedule unchanged so you can inspect the error and retry. Disabling the option at any time before the successful run prevents auto-deletion. + + + Manual runs via **Run Now** also trigger the delete-after-run logic. If you run the task manually and it succeeds, the task removes itself. + + ## Cron Expression Reference Sencho uses standard 5-field cron expressions: @@ -219,3 +248,15 @@ The scan notification shares the same delivery path as every other Sencho alert. ### Scan task fails with "Trivy binary is not available" Trivy must be installed on the node that runs the scan, not just on the primary instance. Follow [Installing Trivy](/operations/trivy-setup) on the target node, then trigger **Run Now** from the schedule to confirm the scan succeeds before waiting for the next cron tick. + +### Start Stack fails with a compose file error + +The Start Stack action runs `docker compose up -d` against the stack's compose directory. If the stack folder has been removed or its compose file is missing, the run fails. Re-create or restore the stack in the [Editor](/features/editor), then trigger **Run Now** to confirm the start succeeds before the next cron tick. + +### Auto-backup always overwrites the previous backup + +Backup Stack Files keeps a single backup slot per stack under `/backups//`. Each run overwrites the previous backup. This is by design for simplicity. If you need a timestamped archive of compose files across all nodes, use a [Fleet Snapshot](/features/fleet-backups) schedule, which stores versioned snapshots. + +### One-shot task disappeared after a successful run + +A task with **Delete after successful run** enabled removes itself automatically after the first successful execution, including its run history. This is expected behavior. If you need a record of the run before deletion, export the execution history to CSV via the clock icon before the next successful run. diff --git a/docs/images/scheduled-operations/create-dialog.png b/docs/images/scheduled-operations/create-dialog.png index 46376d48..d7d01484 100644 Binary files a/docs/images/scheduled-operations/create-dialog.png and b/docs/images/scheduled-operations/create-dialog.png differ diff --git a/docs/images/scheduled-operations/timeline.png b/docs/images/scheduled-operations/timeline.png index 95243f77..f44574e4 100644 Binary files a/docs/images/scheduled-operations/timeline.png and b/docs/images/scheduled-operations/timeline.png differ diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index abe2886c..7fc88f62 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -32,6 +32,10 @@ const ACTION_OPTIONS: Array<{ { value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' }, { value: 'prune', label: 'System Prune', targetType: 'system' }, { value: 'scan', label: 'Vulnerability Scan', targetType: 'system' }, + { value: 'auto_backup', label: 'Backup Stack Files', targetType: 'stack' }, + { value: 'auto_stop', label: 'Stop Stack (keep containers)', targetType: 'stack' }, + { value: 'auto_down', label: 'Take Stack Down (remove containers)', targetType: 'stack' }, + { value: 'auto_start', label: 'Start Stack', targetType: 'stack' }, ]; const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [ @@ -39,6 +43,7 @@ const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: stri { key: 'update', label: 'Update', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)', actions: ['update'] }, { key: 'scan', label: 'Scan', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)', actions: ['scan'] }, { key: 'prune', label: 'Prune', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)', actions: ['prune', 'snapshot'] }, + { key: 'auto_stop', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)', actions: ['auto_stop', 'auto_down', 'auto_start', 'auto_backup'] }, ]; const TIMELINE_WINDOW_HOURS = 24; @@ -90,6 +95,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const [formNodeId, setFormNodeId] = useState(''); const [formCron, setFormCron] = useState('0 3 * * *'); const [formEnabled, setFormEnabled] = useState(true); + const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false); const [formPruneTargets, setFormPruneTargets] = useState(['containers', 'images', 'networks', 'volumes']); const [formTargetServices, setFormTargetServices] = useState([]); const [formPruneLabelFilter, setFormPruneLabelFilter] = useState(''); @@ -210,6 +216,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p setFormNodeId(nodeId); setFormCron('0 3 * * *'); setFormEnabled(true); + setFormDeleteAfterRun(false); setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); setFormTargetServices([]); setFormPruneLabelFilter(''); @@ -225,6 +232,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); setFormEnabled(task.enabled === 1); + setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1); setFormPruneTargets( task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes'] ); @@ -245,6 +253,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p action: actionOption.backendAction ?? formAction, cron_expression: formCron, enabled: formEnabled, + delete_after_run: formDeleteAfterRun, }; if (actionOption.targetType === 'stack') { @@ -793,6 +802,19 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p + + diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index 65703dd5..f4cae67c 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -4,7 +4,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; @@ -17,6 +17,7 @@ export interface ScheduledTask { prune_targets: string | null; target_services: string | null; prune_label_filter: string | null; + delete_after_run?: number; next_runs?: number[]; }