feat(schedules): make every scheduled action available on Skipper (#1141)

Collapse the Admiral carveout that restricted restart, prune, auto_backup,
auto_stop, auto_down, and auto_start schedules to the Admiral variant.
Scheduled Operations stays at Skipper+ (paid). The action picker now lists
every supported operation for any paid admin, and the scheduler runner
executes every action on either variant.
This commit is contained in:
Anso
2026-05-21 16:42:41 -04:00
committed by GitHub
parent 8d1304b0df
commit c491d309c1
8 changed files with 55 additions and 77 deletions
@@ -14,13 +14,14 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
let adminCookie: string;
let viewerCookie: string;
let variantSpy: ReturnType<typeof vi.spyOn>;
let tierSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
variantSpy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
@@ -90,7 +91,7 @@ describe('GET /api/scheduled-tasks', () => {
expect(Array.isArray(res.body[0].next_runs)).toBe(true);
});
it('shows scan and snapshot tasks to Skipper users', async () => {
it('shows every action to Skipper users', async () => {
const db = DatabaseService.getInstance();
const now = Date.now();
db.createScheduledTask({
@@ -154,7 +155,7 @@ describe('GET /api/scheduled-tasks', () => {
const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['scan', 'snapshot']);
expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['prune', 'scan', 'snapshot']);
});
});
@@ -441,16 +442,35 @@ describe('POST /api/scheduled-tasks - Skipper tier gating', () => {
expect(res.body.action).toBe('snapshot');
});
for (const action of ['restart', 'prune', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
it(`rejects Skipper admins from creating ${action} tasks with 403`, async () => {
for (const action of ['restart', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
it(`allows Skipper admins to create ${action} tasks`, async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: `skipper-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1,
action, cron_expression: '0 3 * * *', enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
expect(res.status).toBe(201);
expect(res.body.action).toBe(action);
});
}
it('allows Skipper admins to create prune tasks', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'skipper-prune', target_type: 'system', node_id: 1,
action: 'prune', cron_expression: '0 4 * * *', enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.action).toBe('prune');
});
it('rejects Community admins from creating any scheduled task with 403', async () => {
tierSpy.mockReturnValueOnce('community');
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'community-update', target_type: 'stack', target_id: 'my-stack', node_id: 1,
action: 'update', cron_expression: '0 3 * * *', enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
});
describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
@@ -292,15 +292,17 @@ describe('SchedulerService - license gating', () => {
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
});
it('skips non-update/scan/snapshot tasks for non-admiral pro', async () => {
it('executes restart tasks for non-admiral pro (Skipper)', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('individual');
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
const svc = SchedulerService.getInstance();
await (svc as any).tick();
expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled();
await new Promise(r => setTimeout(r, 50));
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
});
it('allows snapshot tasks for non-admiral pro (Skipper)', async () => {
@@ -1525,7 +1527,7 @@ describe('SchedulerService - lifecycle actions', () => {
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
});
it('non-admiral paid tier skips lifecycle actions', async () => {
it('non-admiral paid tier executes lifecycle actions', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('standard');
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
@@ -1534,7 +1536,8 @@ describe('SchedulerService - lifecycle actions', () => {
const svc = SchedulerService.getInstance();
await (svc as any).tick();
expect(mockRunCommand).not.toHaveBeenCalled();
await new Promise(r => setTimeout(r, 50));
expect(mockRunCommand).toHaveBeenCalled();
});
});
-9
View File
@@ -59,15 +59,6 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => {
return true;
};
/** Scheduled task actions a Skipper-tier license may create and view. All other actions are Admiral-only. */
export const SKIPPER_SCHEDULED_ACTIONS: ReadonlySet<string> = new Set(['update', 'scan', 'snapshot']);
/** Tier gate for scheduled tasks: SKIPPER_SCHEDULED_ACTIONS require Skipper+, everything else requires Admiral. */
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
if (SKIPPER_SCHEDULED_ACTIONS.has(action)) return requirePaid(req, res);
return requireAdmiral(req, res);
};
/**
* Tier gate for SSO providers. The split is by delivery (turnkey vs self-configured), not by
* protocol: Custom OIDC stays free so self-hosters can wire any OIDC IdP (Authelia, Keycloak,
+2 -15
View File
@@ -1,9 +1,8 @@
import { Router, type Request, type Response } from 'express';
import { CronExpressionParser } from 'cron-parser';
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
import { LicenseService } from '../services/LicenseService';
import { SchedulerService } from '../services/SchedulerService';
import { requirePaid, requireAdmin, requireScheduledTaskTier, SKIPPER_SCHEDULED_ACTIONS } from '../middleware/tierGates';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
@@ -109,11 +108,6 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
try {
let tasks = DatabaseService.getInstance().getScheduledTasks();
// Skipper users see v1 fleet-maintenance tasks; Admiral sees all.
const ls = LicenseService.getInstance();
if (ls.getVariant() !== 'admiral') {
tasks = tasks.filter(t => SKIPPER_SCHEDULED_ACTIONS.has(t.action));
}
// Split Auto-Update and Scheduled Operations into distinct views.
const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined;
const excludeAction = typeof req.query.exclude_action === 'string' ? req.query.exclude_action : undefined;
@@ -142,6 +136,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
@@ -154,7 +149,6 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
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;
const targetErr = validateActionTarget(action, target_type);
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
@@ -222,7 +216,6 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => {
if (id === null) return;
const task = DatabaseService.getInstance().getScheduledTask(id);
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(task.action, req, res)) return;
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Get error:', error);
@@ -240,7 +233,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
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, delete_after_run } = req.body;
@@ -322,7 +314,6 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => {
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
db.deleteScheduledTask(id);
console.log(`[ScheduledTasks] Deleted task id=${id}`);
@@ -343,7 +334,6 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void =>
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const newEnabled = existing.enabled ? 0 : 1;
const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null;
@@ -373,7 +363,6 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => {
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const scheduler = SchedulerService.getInstance();
if (scheduler.isTaskRunning(id)) {
@@ -404,7 +393,6 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void
const db = DatabaseService.getInstance();
const task = db.getScheduledTask(id);
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(task.action, req, res)) return;
const runs = db.getAllScheduledTaskRuns(id);
@@ -440,7 +428,6 @@ scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => {
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0);
+1 -7
View File
@@ -195,9 +195,7 @@ export class SchedulerService {
await this.maybeRedetectTrivy();
const ls = LicenseService.getInstance();
const isPaid = ls.getTier() === 'paid';
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
if (!isPaid) return;
if (ls.getTier() !== 'paid') return;
const now = Date.now();
const dueTasks = db.getDueScheduledTasks(now);
@@ -211,10 +209,6 @@ export class SchedulerService {
db.deleteOldScans(90 * 24 * 60 * 60 * 1000);
for (const task of dueTasks) {
if (!isAdmiral && task.action !== 'update' && task.action !== 'scan' && task.action !== 'snapshot') {
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`);
continue;
}
if (this.runningTasks.has(task.id)) {
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: already running`);
continue;