Legacy panel: - upstream body reads now carry their own deadline and a 10 MB byte budget; a stalled modem can no longer hang /api/status fan-out forever nor OOM the proxy (request headers alone had the timeout, bodies had none) - add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel (delete-instance and confirmed-write dialogs) is no longer clickjackable - only send a JSON content-type when the API console request has a body, so payload-less dangerous writes stop failing with 400 and burning the one-use confirmation token - register a form-urlencoded parser (the proxy branch was unreachable) and drop the multipart parser that buffered up to 60 MB before rejecting; bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body - remove /api/sms/send from readable paths: GET bypassed the write confirmation for a send endpoint - /api/instances/:id/login maps upstream failures to a stable 502 instead of leaking raw error text - guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request); prune dead code (buildClients, cookie expando no-op) Control plane: - deleting a notification channel detaches it from rules instead of leaving dangling ids that made every referencing rule unreadable and silently dropped future notifications; rule reads tolerate unknown ids - startup sweep resets notification_queue rows stranded in 'sending' by a crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on the retention timer - /api/v1/metrics no longer emits operator-assigned node names on the session-free scrape; login limiter map is bounded and pruned; Secure cookie honors the gateway-declared x-forwarded-proto - webhook delivery sets redirect: manual (signed payloads are not replayed) - SMTP envelope sender is validated against CR/LF smuggling - scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with a clear reason instead of burning every retry; contract narrowed to the pinned baseline
507 lines
20 KiB
TypeScript
507 lines
20 KiB
TypeScript
import type { PageEnvelope, PageQuery } from './instances.js';
|
|
|
|
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
|
|
export const SCHEDULED_OPERATION_TYPES = [
|
|
'restart-service',
|
|
'reboot-system',
|
|
'restart-baseband',
|
|
'send-sms',
|
|
'backup-data',
|
|
] 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 const SCHEDULE_TRIGGER_KINDS = ['cron', 'fixed', 'interval'] as const;
|
|
export const SCHEDULE_INTERVAL_UNITS = ['mins', 'hours', 'days'] 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 ScheduleTriggerKind = (typeof SCHEDULE_TRIGGER_KINDS)[number];
|
|
export type ScheduleIntervalUnit = (typeof SCHEDULE_INTERVAL_UNITS)[number];
|
|
|
|
export type ScheduleTargetSelector =
|
|
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
|
|
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
|
|
| { readonly mode: 'group'; readonly groupId: string }
|
|
| { readonly mode: 'groups'; readonly groupIds: readonly string[] }
|
|
| { readonly mode: 'all' };
|
|
|
|
/**
|
|
* Hub-style scheduling vocabulary. `cron` keeps the existing five-field engine, `fixed` is a set of
|
|
* weekday/time pairs, and `interval` is a plain wall-clock period.
|
|
*/
|
|
export type ScheduleTrigger =
|
|
| { readonly kind: 'cron'; readonly expression: string }
|
|
| {
|
|
readonly kind: 'fixed';
|
|
readonly weekdays: readonly number[];
|
|
readonly times: readonly string[];
|
|
}
|
|
| { readonly kind: 'interval'; readonly value: number; readonly unit: ScheduleIntervalUnit };
|
|
|
|
export interface ScheduleRetryPolicy {
|
|
readonly maxRetries: number;
|
|
readonly intervalSeconds: number;
|
|
}
|
|
|
|
export interface ScheduledSmsInput {
|
|
readonly recipients: readonly string[];
|
|
readonly content: string;
|
|
/** Hub parity: jitter applied before each send so bulk schedules do not fire simultaneously. */
|
|
readonly randomDelaySeconds?: number;
|
|
}
|
|
|
|
export interface ScheduledSmsSummary {
|
|
readonly configured: boolean;
|
|
readonly recipientCount: number;
|
|
}
|
|
|
|
export interface CreateScheduledTaskRequest {
|
|
readonly name: string;
|
|
readonly operationType: ScheduledOperationType;
|
|
/** Optional so legacy cron-only callers keep working; parsers always fill it in. */
|
|
readonly trigger?: ScheduleTrigger;
|
|
readonly cronExpression: string;
|
|
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
|
readonly targetSelector: ScheduleTargetSelector;
|
|
readonly sms?: ScheduledSmsInput;
|
|
readonly delaySeconds?: number;
|
|
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 trigger?: ScheduleTrigger;
|
|
readonly cronExpression?: string;
|
|
readonly timezone?: typeof AUTOMATION_TIMEZONE;
|
|
readonly targetSelector?: ScheduleTargetSelector;
|
|
readonly sms?: ScheduledSmsInput | null;
|
|
readonly delaySeconds?: number | 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 trigger?: ScheduleTrigger;
|
|
readonly cronExpression: string;
|
|
readonly timezone: typeof AUTOMATION_TIMEZONE;
|
|
readonly targetSelector: ScheduleTargetSelector;
|
|
readonly sms?: ScheduledSmsSummary;
|
|
readonly delaySeconds?: number;
|
|
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',
|
|
'trigger',
|
|
'cronExpression',
|
|
'timezone',
|
|
'targetSelector',
|
|
'sms',
|
|
'delaySeconds',
|
|
'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),
|
|
};
|
|
}
|
|
if (source.mode === 'group') {
|
|
exactKeys(source, new Set(['mode', 'groupId']));
|
|
return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) };
|
|
}
|
|
if (source.mode === 'groups') {
|
|
exactKeys(source, new Set(['mode', 'groupIds']));
|
|
return { mode: 'groups', groupIds: strings(source.groupIds, 'groupIds', 200) };
|
|
}
|
|
if (source.mode === 'all') {
|
|
exactKeys(source, new Set(['mode']));
|
|
return { mode: 'all' };
|
|
}
|
|
throw new TypeError('targetSelector mode is invalid');
|
|
}
|
|
|
|
const INTERVAL_UNIT_MS: Record<ScheduleIntervalUnit, number> = {
|
|
mins: 60_000,
|
|
hours: 3_600_000,
|
|
days: 86_400_000,
|
|
};
|
|
|
|
export function intervalMilliseconds(
|
|
trigger: Extract<ScheduleTrigger, { kind: 'interval' }>,
|
|
): number {
|
|
return trigger.value * INTERVAL_UNIT_MS[trigger.unit];
|
|
}
|
|
|
|
function timeOfDay(value: unknown): string {
|
|
const raw = string(value, 'time', 5);
|
|
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(raw);
|
|
const hour = match?.[1];
|
|
const minute = match?.[2];
|
|
if (!hour || !minute) throw new TypeError('time must use HH:MM syntax');
|
|
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
|
}
|
|
|
|
function weekdays(value: unknown): number[] {
|
|
if (!Array.isArray(value) || value.length === 0 || value.length > 7)
|
|
throw new TypeError('weekdays must contain 1-7 values');
|
|
const result = value.map((item) => {
|
|
if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 7)
|
|
throw new TypeError('weekdays must be between Monday (1) and Sunday (7)');
|
|
return item as number;
|
|
});
|
|
if (new Set(result).size !== result.length) throw new TypeError('weekdays must be unique');
|
|
return result.sort((left, right) => left - right);
|
|
}
|
|
|
|
function times(value: unknown): string[] {
|
|
if (!Array.isArray(value) || value.length === 0 || value.length > 48)
|
|
throw new TypeError('times must contain 1-48 values');
|
|
const result = value.map(timeOfDay);
|
|
if (new Set(result).size !== result.length) throw new TypeError('times must be unique');
|
|
return result.sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function parseScheduleTrigger(value: unknown): ScheduleTrigger {
|
|
const source = object(value, 'trigger');
|
|
if (source.kind === 'cron') {
|
|
exactKeys(source, new Set(['kind', 'expression']));
|
|
return { kind: 'cron', expression: cronExpression(source.expression) };
|
|
}
|
|
if (source.kind === 'fixed') {
|
|
exactKeys(source, new Set(['kind', 'weekdays', 'times']));
|
|
return { kind: 'fixed', weekdays: weekdays(source.weekdays), times: times(source.times) };
|
|
}
|
|
if (source.kind === 'interval') {
|
|
exactKeys(source, new Set(['kind', 'value', 'unit']));
|
|
if (!Number.isSafeInteger(source.value) || (source.value as number) < 1)
|
|
throw new TypeError('interval value must be a positive integer');
|
|
if ((source.value as number) > 10_000)
|
|
throw new TypeError('interval value must be at most 10000');
|
|
return {
|
|
kind: 'interval',
|
|
value: source.value as number,
|
|
unit: member(source.unit, SCHEDULE_INTERVAL_UNITS, 'interval unit'),
|
|
};
|
|
}
|
|
throw new TypeError('trigger kind is invalid');
|
|
}
|
|
|
|
/**
|
|
* Canonical five-field cron kept for the legacy `cron_expression` column and for display. Only the
|
|
* `cron` kind is exact; `fixed` collapses to its earliest pair and `interval` to its step form.
|
|
*/
|
|
export function deriveCronExpression(trigger: ScheduleTrigger): string {
|
|
if (trigger.kind === 'cron') return trigger.expression;
|
|
if (trigger.kind === 'interval') {
|
|
if (trigger.unit === 'mins')
|
|
return trigger.value === 1 ? '* * * * *' : `*/${trigger.value} * * * *`;
|
|
if (trigger.unit === 'hours')
|
|
return trigger.value === 1 ? '0 * * * *' : `0 */${trigger.value} * * *`;
|
|
return trigger.value === 1 ? '0 0 * * *' : `0 0 */${trigger.value} * *`;
|
|
}
|
|
const earliest = trigger.times[0] ?? '00:00';
|
|
const [hour, minute] = earliest.split(':');
|
|
const days = trigger.weekdays.length === 7 ? '*' : trigger.weekdays.join(',');
|
|
return `${Number(minute)} ${Number(hour)} * * ${days}`;
|
|
}
|
|
|
|
/** Resolve the effective trigger for a stored or legacy task. */
|
|
export function scheduleTriggerOf(task: {
|
|
readonly trigger?: ScheduleTrigger;
|
|
readonly cronExpression: string;
|
|
}): ScheduleTrigger {
|
|
return task.trigger ?? { kind: 'cron', expression: task.cronExpression };
|
|
}
|
|
|
|
function sms(value: unknown): ScheduledSmsInput {
|
|
const source = object(value, 'sms');
|
|
exactKeys(source, new Set(['recipients', 'content', 'randomDelaySeconds']));
|
|
const randomDelaySeconds =
|
|
source.randomDelaySeconds === undefined
|
|
? undefined
|
|
: numberInRange(source.randomDelaySeconds, 'sms.randomDelaySeconds', 0, 600);
|
|
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),
|
|
...(randomDelaySeconds === undefined ? {} : { randomDelaySeconds }),
|
|
};
|
|
}
|
|
|
|
function numberInRange(value: unknown, label: string, minimum: number, maximum: number): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum)
|
|
throw new TypeError(`${label} must be between ${minimum} and ${maximum}`);
|
|
return value as number;
|
|
}
|
|
|
|
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 = numberInRange(source.maxRetries, 'retryPolicy.maxRetries', 0, 10);
|
|
const intervalSeconds = numberInRange(
|
|
source.intervalSeconds,
|
|
'retryPolicy.intervalSeconds',
|
|
1,
|
|
86_400,
|
|
);
|
|
if (operationType === 'reboot-system' && maxRetries > 0)
|
|
throw new TypeError('System reboot automatic retries are not supported');
|
|
return { maxRetries, intervalSeconds };
|
|
}
|
|
|
|
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');
|
|
if (source.trigger === undefined && source.cronExpression === undefined)
|
|
throw new TypeError('trigger or cronExpression is required');
|
|
const trigger =
|
|
source.trigger === undefined
|
|
? ({ kind: 'cron', expression: cronExpression(source.cronExpression) } as const)
|
|
: parseScheduleTrigger(source.trigger);
|
|
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');
|
|
// The upstream execution baseline pins reboot delay_seconds to 3; accepting a
|
|
// wider range here would only produce tasks that can never run.
|
|
const delaySeconds =
|
|
source.delaySeconds === undefined
|
|
? undefined
|
|
: numberInRange(source.delaySeconds, 'delaySeconds', 3, 3);
|
|
if (delaySeconds !== undefined && operationType !== 'reboot-system')
|
|
throw new TypeError('delaySeconds is only valid for reboot-system');
|
|
return {
|
|
name: string(source.name, 'name', 120),
|
|
operationType,
|
|
trigger,
|
|
cronExpression: deriveCronExpression(trigger),
|
|
timezone: AUTOMATION_TIMEZONE,
|
|
targetSelector: targetSelector(source.targetSelector),
|
|
...(smsValue ? { sms: smsValue } : {}),
|
|
...(delaySeconds === undefined ? {} : { delaySeconds }),
|
|
...(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.trigger !== undefined) {
|
|
result.trigger = parseScheduleTrigger(source.trigger);
|
|
result.cronExpression = deriveCronExpression(result.trigger as ScheduleTrigger);
|
|
} else if (source.cronExpression !== undefined) {
|
|
result.cronExpression = cronExpression(source.cronExpression);
|
|
result.trigger = { kind: 'cron', expression: result.cronExpression } as ScheduleTrigger;
|
|
}
|
|
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.delaySeconds !== undefined)
|
|
result.delaySeconds =
|
|
source.delaySeconds === null
|
|
? null
|
|
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
|
|
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;
|
|
}
|