feat: rebuild warm operations workbench
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseCreateScheduledTaskRequest, parseUpdateScheduledTaskRequest } from './automation.js';
|
||||
|
||||
const restartRequest = {
|
||||
name: 'Morning restart',
|
||||
operationType: 'restart-service',
|
||||
cronExpression: '0 9 * * *',
|
||||
targetSelector: { mode: 'fixed', instanceIds: ['instance-a', 'instance-b'] },
|
||||
};
|
||||
|
||||
describe('automation contracts', () => {
|
||||
it('applies the approved timezone and safe default policies', () => {
|
||||
expect(parseCreateScheduledTaskRequest(restartRequest)).toMatchObject({
|
||||
timezone: 'Asia/Shanghai',
|
||||
misfirePolicy: 'skip',
|
||||
overlapPolicy: 'skip',
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects alternate timezones, seconds fields, and unknown keys', () => {
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, timezone: 'UTC' })).toThrow(
|
||||
/timezone/i,
|
||||
);
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({ ...restartRequest, cronExpression: '* * * * * *' }),
|
||||
).toThrow(/five-field/i);
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, surprise: true })).toThrow(
|
||||
/unknown/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('validates fixed and dynamic target selectors without silently dropping values', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
targetSelector: { mode: 'tags', match: 'all', tags: ['lab', 'east'] },
|
||||
}).targetSelector,
|
||||
).toEqual({ mode: 'tags', match: 'all', tags: ['lab', 'east'] });
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
targetSelector: { mode: 'fixed', instanceIds: [] },
|
||||
}),
|
||||
).toThrow(/instance/i);
|
||||
});
|
||||
|
||||
it('requires bounded recipients and content only for SMS schedules', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'send-sms',
|
||||
sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' },
|
||||
}).sms,
|
||||
).toEqual({ recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' });
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'send-sms',
|
||||
sms: { recipients: [], content: '' },
|
||||
}),
|
||||
).toThrow(/sms/i);
|
||||
expect(() =>
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'restart-service',
|
||||
sms: { recipients: ['13800138000'], content: 'not allowed' },
|
||||
}),
|
||||
).toThrow(/sms/i);
|
||||
});
|
||||
|
||||
it('defaults system reboot to no retry and accepts bounded per-task policies', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
operationType: 'reboot-system',
|
||||
misfirePolicy: 'catch-up-once',
|
||||
overlapPolicy: 'queue-once',
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 300 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
misfirePolicy: 'catch-up-once',
|
||||
overlapPolicy: 'queue-once',
|
||||
retryPolicy: { maxRetries: 0, intervalSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an optimistic version and at least one editable field for updates', () => {
|
||||
expect(parseUpdateScheduledTaskRequest({ version: 2, name: 'Updated name' })).toEqual({
|
||||
version: 2,
|
||||
name: 'Updated name',
|
||||
});
|
||||
expect(() => parseUpdateScheduledTaskRequest({ version: 2 })).toThrow(/field/i);
|
||||
expect(() => parseUpdateScheduledTaskRequest({ version: 0, name: 'bad' })).toThrow(/version/i);
|
||||
});
|
||||
|
||||
it('rejects non-boolean enabled values instead of coercing them to false', () => {
|
||||
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, enabled: 'false' })).toThrow(
|
||||
/enabled/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { PageEnvelope, PageQuery } from './instances.js';
|
||||
|
||||
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
|
||||
export const SCHEDULED_OPERATION_TYPES = ['restart-service', 'reboot-system', 'send-sms'] as const;
|
||||
export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const;
|
||||
export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
|
||||
export const SCHEDULED_RUN_OUTCOMES = [
|
||||
'succeeded',
|
||||
'partially-succeeded',
|
||||
'failed',
|
||||
'skipped',
|
||||
'no-targets',
|
||||
'needs-attention',
|
||||
] as const;
|
||||
export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const;
|
||||
|
||||
export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number];
|
||||
export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
|
||||
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
|
||||
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number];
|
||||
export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number];
|
||||
|
||||
export type ScheduleTargetSelector =
|
||||
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
|
||||
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] };
|
||||
|
||||
export interface ScheduleRetryPolicy {
|
||||
readonly maxRetries: number;
|
||||
readonly intervalSeconds: number;
|
||||
}
|
||||
|
||||
export interface ScheduledSmsInput {
|
||||
readonly recipients: readonly string[];
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface ScheduledSmsSummary {
|
||||
readonly configured: boolean;
|
||||
readonly recipientCount: number;
|
||||
}
|
||||
|
||||
export interface CreateScheduledTaskRequest {
|
||||
readonly name: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly cronExpression: string;
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsInput;
|
||||
readonly effectiveStartAt?: string;
|
||||
readonly effectiveEndAt?: string;
|
||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||
readonly overlapPolicy: ScheduleOverlapPolicy;
|
||||
readonly retryPolicy: ScheduleRetryPolicy;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateScheduledTaskRequest {
|
||||
readonly version: number;
|
||||
readonly name?: string;
|
||||
readonly operationType?: ScheduledOperationType;
|
||||
readonly cronExpression?: string;
|
||||
readonly timezone?: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector?: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsInput | null;
|
||||
readonly effectiveStartAt?: string | null;
|
||||
readonly effectiveEndAt?: string | null;
|
||||
readonly misfirePolicy?: ScheduleMisfirePolicy;
|
||||
readonly overlapPolicy?: ScheduleOverlapPolicy;
|
||||
readonly retryPolicy?: ScheduleRetryPolicy;
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly cronExpression: string;
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly targetSelector: ScheduleTargetSelector;
|
||||
readonly sms?: ScheduledSmsSummary;
|
||||
readonly effectiveStartAt?: string;
|
||||
readonly effectiveEndAt?: string;
|
||||
readonly misfirePolicy: ScheduleMisfirePolicy;
|
||||
readonly overlapPolicy: ScheduleOverlapPolicy;
|
||||
readonly retryPolicy: ScheduleRetryPolicy;
|
||||
readonly enabled: boolean;
|
||||
readonly version: number;
|
||||
readonly nextDueAt?: string;
|
||||
readonly lastEvaluatedAt?: string;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScheduledRun {
|
||||
readonly id: string;
|
||||
readonly scheduledTaskId: string;
|
||||
readonly scheduleVersion: number;
|
||||
readonly taskName: string;
|
||||
readonly operationType: ScheduledOperationType;
|
||||
readonly dueAt: string;
|
||||
readonly claimedAt: string;
|
||||
readonly startedAt?: string;
|
||||
readonly finishedAt?: string;
|
||||
readonly targetSnapshot: readonly string[];
|
||||
readonly outcome?: ScheduledRunOutcome;
|
||||
readonly reason?: string;
|
||||
readonly jobIds: readonly string[];
|
||||
readonly triggerSource: ScheduledRunTriggerSource;
|
||||
readonly attempt: number;
|
||||
}
|
||||
|
||||
export type ScheduledTaskPageQuery = PageQuery<'name' | 'nextDueAt' | 'updatedAt'> & {
|
||||
readonly enabled?: boolean;
|
||||
readonly operationType?: ScheduledOperationType;
|
||||
};
|
||||
export type ScheduledTaskPage = PageEnvelope<ScheduledTask>;
|
||||
export type ScheduledRunPageQuery = PageQuery<'dueAt' | 'finishedAt'> & {
|
||||
readonly scheduledTaskId?: string;
|
||||
readonly outcome?: ScheduledRunOutcome;
|
||||
};
|
||||
export type ScheduledRunPage = PageEnvelope<ScheduledRun>;
|
||||
|
||||
export interface CronPreview {
|
||||
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
||||
readonly occurrences: readonly string[];
|
||||
}
|
||||
|
||||
const CREATE_KEYS = new Set([
|
||||
'name',
|
||||
'operationType',
|
||||
'cronExpression',
|
||||
'timezone',
|
||||
'targetSelector',
|
||||
'sms',
|
||||
'effectiveStartAt',
|
||||
'effectiveEndAt',
|
||||
'misfirePolicy',
|
||||
'overlapPolicy',
|
||||
'retryPolicy',
|
||||
'enabled',
|
||||
]);
|
||||
const UPDATE_KEYS = new Set([...CREATE_KEYS, 'version']);
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
throw new TypeError(`${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): void {
|
||||
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
||||
if (unknown) throw new TypeError(`Unknown field: ${unknown}`);
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string, maximum: number): string {
|
||||
if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
|
||||
const clean = value.trim();
|
||||
if (clean.length === 0 || clean.length > maximum)
|
||||
throw new TypeError(`${label} must contain 1-${maximum} characters`);
|
||||
return clean;
|
||||
}
|
||||
|
||||
function member<T extends string>(value: unknown, values: readonly T[], label: string): T {
|
||||
if (typeof value !== 'string' || !values.includes(value as T))
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string, maximum: number): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > maximum)
|
||||
throw new TypeError(`${label} must contain 1-${maximum} values`);
|
||||
const result = value.map((item) => string(item, label, 120));
|
||||
if (new Set(result).size !== result.length) throw new TypeError(`${label} must be unique`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function cronExpression(value: unknown): string {
|
||||
const expression = string(value, 'cronExpression', 120).replace(/\s+/g, ' ');
|
||||
if (expression.split(' ').length !== 5)
|
||||
throw new TypeError('cronExpression must use standard five-field syntax');
|
||||
return expression;
|
||||
}
|
||||
|
||||
function isoDate(value: unknown, label: string): string {
|
||||
const raw = string(value, label, 64);
|
||||
const time = Date.parse(raw);
|
||||
if (!Number.isFinite(time)) throw new TypeError(`${label} must be an ISO timestamp`);
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function targetSelector(value: unknown): ScheduleTargetSelector {
|
||||
const source = object(value, 'targetSelector');
|
||||
if (source.mode === 'fixed') {
|
||||
exactKeys(source, new Set(['mode', 'instanceIds']));
|
||||
return { mode: 'fixed', instanceIds: strings(source.instanceIds, 'instanceIds', 200) };
|
||||
}
|
||||
if (source.mode === 'tags') {
|
||||
exactKeys(source, new Set(['mode', 'match', 'tags']));
|
||||
return {
|
||||
mode: 'tags',
|
||||
match: member(source.match, ['any', 'all'] as const, 'tag match'),
|
||||
tags: strings(source.tags, 'tags', 200),
|
||||
};
|
||||
}
|
||||
throw new TypeError('targetSelector mode is invalid');
|
||||
}
|
||||
|
||||
function sms(value: unknown): ScheduledSmsInput {
|
||||
const source = object(value, 'sms');
|
||||
exactKeys(source, new Set(['recipients', 'content']));
|
||||
return {
|
||||
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
|
||||
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
|
||||
return recipient;
|
||||
}),
|
||||
content: string(source.content, 'SMS content', 2_000),
|
||||
};
|
||||
}
|
||||
|
||||
function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy {
|
||||
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
|
||||
const source = object(value, 'retryPolicy');
|
||||
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
|
||||
const maxRetries = source.maxRetries;
|
||||
const intervalSeconds = source.intervalSeconds;
|
||||
if (
|
||||
!Number.isSafeInteger(maxRetries) ||
|
||||
(maxRetries as number) < 0 ||
|
||||
(maxRetries as number) > 10
|
||||
)
|
||||
throw new TypeError('retryPolicy.maxRetries must be between 0 and 10');
|
||||
if (
|
||||
!Number.isSafeInteger(intervalSeconds) ||
|
||||
(intervalSeconds as number) < 1 ||
|
||||
(intervalSeconds as number) > 86_400
|
||||
)
|
||||
throw new TypeError('retryPolicy.intervalSeconds must be between 1 and 86400');
|
||||
if (operationType === 'reboot-system' && (maxRetries as number) > 0)
|
||||
throw new TypeError('System reboot automatic retries are not supported');
|
||||
return { maxRetries: maxRetries as number, intervalSeconds: intervalSeconds as number };
|
||||
}
|
||||
|
||||
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
|
||||
const source = object(value, 'schedule');
|
||||
exactKeys(source, CREATE_KEYS);
|
||||
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
|
||||
if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai');
|
||||
const smsValue = source.sms === undefined ? undefined : sms(source.sms);
|
||||
if (operationType === 'send-sms' && !smsValue)
|
||||
throw new TypeError('SMS configuration is required');
|
||||
if (operationType !== 'send-sms' && smsValue)
|
||||
throw new TypeError('SMS is only valid for send-sms');
|
||||
const effectiveStartAt =
|
||||
source.effectiveStartAt === undefined
|
||||
? undefined
|
||||
: isoDate(source.effectiveStartAt, 'effectiveStartAt');
|
||||
const effectiveEndAt =
|
||||
source.effectiveEndAt === undefined
|
||||
? undefined
|
||||
: isoDate(source.effectiveEndAt, 'effectiveEndAt');
|
||||
if (effectiveStartAt && effectiveEndAt && effectiveEndAt <= effectiveStartAt)
|
||||
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
|
||||
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
|
||||
throw new TypeError('enabled must be boolean');
|
||||
return {
|
||||
name: string(source.name, 'name', 120),
|
||||
operationType,
|
||||
cronExpression: cronExpression(source.cronExpression),
|
||||
timezone: AUTOMATION_TIMEZONE,
|
||||
targetSelector: targetSelector(source.targetSelector),
|
||||
...(smsValue ? { sms: smsValue } : {}),
|
||||
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
||||
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
||||
misfirePolicy:
|
||||
source.misfirePolicy === undefined
|
||||
? 'skip'
|
||||
: member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy'),
|
||||
overlapPolicy:
|
||||
source.overlapPolicy === undefined
|
||||
? 'skip'
|
||||
: member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy'),
|
||||
retryPolicy: retryPolicy(source.retryPolicy, operationType),
|
||||
enabled: source.enabled === undefined ? true : source.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduledTaskRequest {
|
||||
const source = object(value, 'schedule update');
|
||||
exactKeys(source, UPDATE_KEYS);
|
||||
if (!Number.isSafeInteger(source.version) || (source.version as number) < 1)
|
||||
throw new TypeError('version must be a positive integer');
|
||||
if (Object.keys(source).length === 1)
|
||||
throw new TypeError('At least one editable field is required');
|
||||
|
||||
const result: Record<string, unknown> = { version: source.version };
|
||||
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
|
||||
if (source.operationType !== undefined)
|
||||
result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
|
||||
if (source.cronExpression !== undefined)
|
||||
result.cronExpression = cronExpression(source.cronExpression);
|
||||
if (source.timezone !== undefined) {
|
||||
if (source.timezone !== AUTOMATION_TIMEZONE)
|
||||
throw new TypeError('timezone must be Asia/Shanghai');
|
||||
result.timezone = AUTOMATION_TIMEZONE;
|
||||
}
|
||||
if (source.targetSelector !== undefined)
|
||||
result.targetSelector = targetSelector(source.targetSelector);
|
||||
if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms);
|
||||
if (source.effectiveStartAt !== undefined)
|
||||
result.effectiveStartAt =
|
||||
source.effectiveStartAt === null
|
||||
? null
|
||||
: isoDate(source.effectiveStartAt, 'effectiveStartAt');
|
||||
if (source.effectiveEndAt !== undefined)
|
||||
result.effectiveEndAt =
|
||||
source.effectiveEndAt === null ? null : isoDate(source.effectiveEndAt, 'effectiveEndAt');
|
||||
if (source.misfirePolicy !== undefined)
|
||||
result.misfirePolicy = member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy');
|
||||
if (source.overlapPolicy !== undefined)
|
||||
result.overlapPolicy = member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy');
|
||||
if (source.retryPolicy !== undefined)
|
||||
result.retryPolicy = retryPolicy(
|
||||
source.retryPolicy,
|
||||
(result.operationType ?? 'restart-service') as ScheduledOperationType,
|
||||
);
|
||||
if (source.enabled !== undefined) {
|
||||
if (typeof source.enabled !== 'boolean') throw new TypeError('enabled must be boolean');
|
||||
result.enabled = source.enabled;
|
||||
}
|
||||
return result as unknown as UpdateScheduledTaskRequest;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './audit.js';
|
||||
export * from './automation.js';
|
||||
export * from './errors.js';
|
||||
export * from './instances.js';
|
||||
export * from './jobs.js';
|
||||
|
||||
Reference in New Issue
Block a user