@celilo/cli 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +27 -7
- package/package.json +6 -5
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +70 -3
- package/src/cli/commands/module-config.ts +262 -28
- package/src/cli/commands/module-status.ts +155 -12
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +35 -9
- package/src/cli/index.ts +59 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/sweep-runner.test.ts +51 -3
- package/src/services/alerting/sweep-runner.ts +30 -7
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +33 -13
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- package/src/variables/declarative-derivation.ts +13 -2
|
@@ -6,30 +6,120 @@
|
|
|
6
6
|
* one. If those two read the manifest differently, a module can be
|
|
7
7
|
* alerted-on-but-never-backed-up — an alert no human action can clear.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* The manifest is the author's SUGGESTION about a fleet they have never
|
|
10
|
+
* seen; the operator's `backup_schedule` override wins. Resolution
|
|
11
|
+
* happens HERE, at read time, so a corrected manifest still reaches
|
|
12
|
+
* every install that has not overridden it.
|
|
13
|
+
*
|
|
14
|
+
* Absent from both means `daily`, not `manual`. Treating "nobody said"
|
|
10
15
|
* as "never check and never run" is what let celilo-mgmt go 55 days
|
|
11
16
|
* without a backup and forgejo and signal go without one entirely, all
|
|
12
17
|
* silently. Opting out is a decision worth writing down, so it takes an
|
|
13
|
-
* explicit `
|
|
18
|
+
* explicit `manual`.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import type { ModuleManifest } from '../manifest/schema';
|
|
22
|
+
import { type Cadence, cadenceMs, parseCadence } from './cadence';
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
/** The `module_configs` key an operator's backup cadence is stored under. */
|
|
25
|
+
export const BACKUP_SCHEDULE_CONFIG_KEY = 'backup_schedule';
|
|
19
26
|
|
|
20
|
-
/** Used when
|
|
21
|
-
export const DEFAULT_BACKUP_SCHEDULE:
|
|
27
|
+
/** Used when neither the operator nor the manifest says anything. */
|
|
28
|
+
export const DEFAULT_BACKUP_SCHEDULE: Cadence = { minutes: 24 * 60 };
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
/**
|
|
31
|
+
* `override` is the raw stored value of `backup_schedule`, or undefined when
|
|
32
|
+
* the operator has set none. An unparseable override falls back to the
|
|
33
|
+
* manifest rather than to `manual`: values are validated at SET time, so a bad
|
|
34
|
+
* one here means hand-edited state, and the safe direction is backing up more
|
|
35
|
+
* often than asked, never less.
|
|
36
|
+
*/
|
|
37
|
+
export function effectiveBackupSchedule(
|
|
38
|
+
manifest: ModuleManifest,
|
|
39
|
+
override: string | undefined,
|
|
40
|
+
): Cadence {
|
|
41
|
+
if (override !== undefined) {
|
|
42
|
+
const chosen = parseCadence(override);
|
|
43
|
+
if (chosen !== null) return chosen;
|
|
44
|
+
}
|
|
24
45
|
const declared = manifest.backup?.schedule;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
case 'weekly':
|
|
29
|
-
case 'monthly':
|
|
30
|
-
case 'manual':
|
|
31
|
-
return declared;
|
|
32
|
-
default:
|
|
33
|
-
return DEFAULT_BACKUP_SCHEDULE;
|
|
46
|
+
if (declared !== undefined) {
|
|
47
|
+
const suggested = parseCadence(declared);
|
|
48
|
+
if (suggested !== null) return suggested;
|
|
34
49
|
}
|
|
50
|
+
return DEFAULT_BACKUP_SCHEDULE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Failures in a row after which retrying stops being worth the resources.
|
|
55
|
+
*
|
|
56
|
+
* Under this many, a failure is assumed transient and the module stays due on
|
|
57
|
+
* the next tick, because most failures ARE transient — a storage endpoint
|
|
58
|
+
* having a bad minute should not cost a full cadence period of coverage.
|
|
59
|
+
*
|
|
60
|
+
* At or over it, the evidence says otherwise and the retry slows to the
|
|
61
|
+
* module's own cadence. Three is deliberately small: the useful information
|
|
62
|
+
* from a retry is almost entirely in the first one or two, and the cost of
|
|
63
|
+
* being wrong in this direction is bounded (one delayed backup) while the cost
|
|
64
|
+
* of the other direction is not.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_RAPID_RETRIES = 3;
|
|
67
|
+
|
|
68
|
+
/** What the backup history says about one module, for the due-ness decision. */
|
|
69
|
+
export interface BackupHistory {
|
|
70
|
+
/**
|
|
71
|
+
* When the last successful backup COMPLETED, or null if there has never
|
|
72
|
+
* been one. Completion, not start — the same instant the freshness audit
|
|
73
|
+
* measures from, so the run path and the alert path cannot disagree about
|
|
74
|
+
* how old a backup is (design.md D6).
|
|
75
|
+
*/
|
|
76
|
+
lastSuccessAt: Date | null;
|
|
77
|
+
/** Last attempt of any outcome, or null if none has ever been made. */
|
|
78
|
+
lastAttemptAt: Date | null;
|
|
79
|
+
/** Attempts since the last success. */
|
|
80
|
+
consecutiveFailures: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether to start a backup for a module right now.
|
|
85
|
+
*
|
|
86
|
+
* Due-ness used to be "has it been an interval since the last SUCCESS", which
|
|
87
|
+
* is correct in the healthy case and degenerate in the failing one: a module
|
|
88
|
+
* that cannot back up never advances that timestamp, so it is due at every
|
|
89
|
+
* tick forever. The sweep runs hourly, so a *daily* module that started failing
|
|
90
|
+
* was re-picked 24 times a day. On celilo-mgr that meant 20+ consecutive
|
|
91
|
+
* forgejo attempts, each one assembling ~1.9 GB of staging and then dying, none
|
|
92
|
+
* of them ever going to succeed for a reason no retry could change (celilo#685).
|
|
93
|
+
*
|
|
94
|
+
* Retrying is still right — it just cannot be unconditional. So the failure
|
|
95
|
+
* count decides which clock applies: under `MAX_RAPID_RETRIES` the module stays
|
|
96
|
+
* due against its last success, and at or over it the interval is measured from
|
|
97
|
+
* the last ATTEMPT instead, which turns 24 doomed attempts a day into one.
|
|
98
|
+
*
|
|
99
|
+
* Backing off is not the same as going quiet. The `backups` drift monitor
|
|
100
|
+
* measures staleness from the last success and is unaffected by this, so a
|
|
101
|
+
* module that has slowed to one attempt a day still alerts as stale on exactly
|
|
102
|
+
* the schedule it would have before — see services/audit/backups.ts.
|
|
103
|
+
*
|
|
104
|
+
* Pure, and time is a parameter, so the policy tests without a database.
|
|
105
|
+
*/
|
|
106
|
+
export function isBackupDueFromHistory(
|
|
107
|
+
schedule: Cadence,
|
|
108
|
+
history: BackupHistory,
|
|
109
|
+
now: number,
|
|
110
|
+
): boolean {
|
|
111
|
+
if (schedule === 'manual') return false;
|
|
112
|
+
|
|
113
|
+
const interval = cadenceMs(schedule);
|
|
114
|
+
|
|
115
|
+
if (history.consecutiveFailures >= MAX_RAPID_RETRIES) {
|
|
116
|
+
// A run of failures with no attempt recorded is not a state the sweep can
|
|
117
|
+
// produce, but "cannot prove an interval has passed" must not mean "start
|
|
118
|
+
// another 1.9 GB attempt".
|
|
119
|
+
if (!history.lastAttemptAt) return false;
|
|
120
|
+
return now - history.lastAttemptAt.getTime() >= interval;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!history.lastSuccessAt) return true;
|
|
124
|
+
return now - history.lastSuccessAt.getTime() >= interval;
|
|
35
125
|
}
|
|
@@ -95,9 +95,22 @@ export interface ReapedStaging {
|
|
|
95
95
|
* drift check can read a module as recently backed up when every attempt in
|
|
96
96
|
* fact died. Nine such rows were live on celilo-mgr while forgejo had no usable
|
|
97
97
|
* backup at all.
|
|
98
|
+
*
|
|
99
|
+
* It names no cause because this pass genuinely cannot know one: it runs later,
|
|
100
|
+
* in a different process, and infers the death from a pid that is no longer
|
|
101
|
+
* there. The observer that DOES know is the dispatcher, which holds the exit
|
|
102
|
+
* code and translates it (`describeHandlerExit` in packages/event-bus) — a
|
|
103
|
+
* SIGKILL, say, and the OOM killer that most likely sent it.
|
|
104
|
+
*
|
|
105
|
+
* The two facts existing in two places is fine; the operator having no way to
|
|
106
|
+
* learn that is not. In celilo#685 this string was the whole of what an
|
|
107
|
+
* operator saw for twenty consecutive OOM kills, so it now points at the record
|
|
108
|
+
* that has the answer rather than terminating the trail.
|
|
98
109
|
*/
|
|
99
110
|
export const ABANDONED_BACKUP_MESSAGE =
|
|
100
|
-
'abandoned — the backup process ended without recording an outcome'
|
|
111
|
+
'abandoned — the backup process ended without recording an outcome. ' +
|
|
112
|
+
'Only the process that ran it saw how it died, so the cause is recorded ' +
|
|
113
|
+
'against the event delivery rather than here: run `celilo system doctor`.';
|
|
101
114
|
|
|
102
115
|
/**
|
|
103
116
|
* Whether reclaiming this directory also means its record was lying about
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import type { ModuleManifest } from '../manifest/schema';
|
|
3
|
-
import type { BackupSchedule } from './backup-schedule';
|
|
4
3
|
import {
|
|
5
4
|
BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
6
5
|
BACKUP_SWEEP_PATTERN,
|
|
@@ -13,13 +12,13 @@ import {
|
|
|
13
12
|
} from './backup-sweep';
|
|
14
13
|
import { InFlightError } from './module-operations';
|
|
15
14
|
|
|
16
|
-
function moduleWith(id: string, schedule?:
|
|
15
|
+
function moduleWith(id: string, schedule?: string, scheduleOverride?: string): BackupSweepModule {
|
|
17
16
|
const manifest = {
|
|
18
17
|
id,
|
|
19
18
|
hooks: { on_backup: { script: 'backup.ts' } },
|
|
20
19
|
...(schedule ? { backup: { schedule } } : {}),
|
|
21
20
|
} as unknown as ModuleManifest;
|
|
22
|
-
return { id, manifest };
|
|
21
|
+
return { id, manifest, scheduleOverride };
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
function deps(
|
|
@@ -123,6 +122,26 @@ describe('runBackupSweep', () => {
|
|
|
123
122
|
expect(report.skippedManual).toEqual(['scratch']);
|
|
124
123
|
});
|
|
125
124
|
|
|
125
|
+
test("an operator's override decides the cadence, not the manifest", async () => {
|
|
126
|
+
const d = deps([moduleWith('caddy', 'daily', '6h')]);
|
|
127
|
+
const seen: Array<[string, unknown]> = [];
|
|
128
|
+
d.isDue = (moduleId, schedule) => {
|
|
129
|
+
seen.push([moduleId, schedule]);
|
|
130
|
+
return true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await runBackupSweep(d);
|
|
134
|
+
|
|
135
|
+
expect(seen).toEqual([['caddy', { minutes: 360 }]]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('an override of manual stops a module the manifest wanted backed up', async () => {
|
|
139
|
+
const report = await runBackupSweep(deps([moduleWith('caddy', 'daily', 'manual')]));
|
|
140
|
+
|
|
141
|
+
expect(report.backedUp).toEqual([]);
|
|
142
|
+
expect(report.skippedManual).toEqual(['caddy']);
|
|
143
|
+
});
|
|
144
|
+
|
|
126
145
|
test('an undeclared schedule is backed up, not treated as manual', async () => {
|
|
127
146
|
// The regression this whole subsystem exists for: forgejo and signal
|
|
128
147
|
// declare no `backup:` block and had never been backed up.
|
|
@@ -17,12 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { ModuleManifest } from '../manifest/schema';
|
|
20
|
-
import {
|
|
20
|
+
import { effectiveBackupSchedule } from './backup-schedule';
|
|
21
21
|
import type { ReapStagingReport, ResolveAbandonedReport } from './backup-staging';
|
|
22
|
+
import { BACKUP_SWEEP_PATTERN, type Cadence } from './cadence';
|
|
22
23
|
import { InFlightError } from './module-operations';
|
|
23
24
|
|
|
24
25
|
export const BACKUP_SWEEP_SUBSCRIBER = 'celilo-backup-sweep';
|
|
25
|
-
|
|
26
|
+
// The tick itself lives in services/cadence.ts, next to the floor derived from
|
|
27
|
+
// it — a sweep whose tick and whose finest servable cadence are stated in two
|
|
28
|
+
// files is the pair that drifts.
|
|
29
|
+
export { BACKUP_SWEEP_PATTERN };
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* How long the sweep may run before the dispatcher kills it.
|
|
@@ -99,12 +103,18 @@ export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
|
|
|
99
103
|
export interface BackupSweepModule {
|
|
100
104
|
id: string;
|
|
101
105
|
manifest: ModuleManifest;
|
|
106
|
+
/**
|
|
107
|
+
* The operator's `backup_schedule` override, or undefined when they have set
|
|
108
|
+
* none. Carried rather than pre-resolved so the cadence is resolved through
|
|
109
|
+
* the one shared accessor here, the same way the freshness audit resolves it.
|
|
110
|
+
*/
|
|
111
|
+
scheduleOverride: string | undefined;
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
export interface BackupSweepDeps {
|
|
105
115
|
/** Installed modules that declare an `on_backup` hook. */
|
|
106
116
|
listEligible(): BackupSweepModule[];
|
|
107
|
-
isDue(moduleId: string, schedule:
|
|
117
|
+
isDue(moduleId: string, schedule: Cadence): boolean;
|
|
108
118
|
backup(moduleId: string): Promise<{ success: boolean; error?: string }>;
|
|
109
119
|
/** Apply the module's declared retention. No-op when it declares none. */
|
|
110
120
|
prune(module: BackupSweepModule): Promise<void>;
|
|
@@ -126,7 +136,7 @@ export interface BackupSweepReport {
|
|
|
126
136
|
/** Records corrected from a stale `in_progress`. */
|
|
127
137
|
records: ResolveAbandonedReport;
|
|
128
138
|
backedUp: string[];
|
|
129
|
-
/**
|
|
139
|
+
/** Effective cadence `manual` — the operator or the author opted out. */
|
|
130
140
|
skippedManual: string[];
|
|
131
141
|
skippedNotDue: string[];
|
|
132
142
|
/** Another module operation held the lock. Not a failure; retried next tick. */
|
|
@@ -157,7 +167,7 @@ export async function runBackupSweep(deps: BackupSweepDeps): Promise<BackupSweep
|
|
|
157
167
|
};
|
|
158
168
|
|
|
159
169
|
for (const module of deps.listEligible()) {
|
|
160
|
-
const schedule = effectiveBackupSchedule(module.manifest);
|
|
170
|
+
const schedule = effectiveBackupSchedule(module.manifest, module.scheduleOverride);
|
|
161
171
|
if (schedule === 'manual') {
|
|
162
172
|
report.skippedManual.push(module.id);
|
|
163
173
|
continue;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
ALERTING_SWEEP_PATTERN,
|
|
4
|
+
BACKUP_CADENCE_FLOOR_MINUTES,
|
|
5
|
+
BACKUP_SWEEP_PATTERN,
|
|
6
|
+
MONITOR_INTERVAL_FLOOR_MINUTES,
|
|
7
|
+
cadenceMs,
|
|
8
|
+
cadenceSchema,
|
|
9
|
+
formatCadence,
|
|
10
|
+
parseCadence,
|
|
11
|
+
tickIntervalMinutes,
|
|
12
|
+
} from './cadence';
|
|
13
|
+
|
|
14
|
+
describe('parseCadence', () => {
|
|
15
|
+
test('every named period equals its duration equivalent', () => {
|
|
16
|
+
expect(parseCadence('hourly')).toEqual(parseCadence('1h'));
|
|
17
|
+
expect(parseCadence('daily')).toEqual(parseCadence('24h'));
|
|
18
|
+
expect(parseCadence('weekly')).toEqual(parseCadence('7d'));
|
|
19
|
+
expect(parseCadence('monthly')).toEqual(parseCadence('30d'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('durations normalise to minutes', () => {
|
|
23
|
+
expect(parseCadence('90m')).toEqual({ minutes: 90 });
|
|
24
|
+
expect(parseCadence('6h')).toEqual({ minutes: 360 });
|
|
25
|
+
expect(parseCadence('3d')).toEqual({ minutes: 4320 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('manual is its own value, not a number', () => {
|
|
29
|
+
expect(parseCadence('manual')).toBe('manual');
|
|
30
|
+
expect(cadenceMs('manual')).toBe(Number.POSITIVE_INFINITY);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('rejects malformed, zero and negative values', () => {
|
|
34
|
+
for (const bad of ['dailyy', '', '6', 'h6', '6w', '0h', '-1h', '1.5h', 'never']) {
|
|
35
|
+
expect(parseCadence(bad)).toBeNull();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('formatCadence', () => {
|
|
41
|
+
test('prefers the word an operator would have typed', () => {
|
|
42
|
+
expect(formatCadence({ minutes: 60 })).toBe('hourly');
|
|
43
|
+
expect(formatCadence({ minutes: 1440 })).toBe('daily');
|
|
44
|
+
expect(formatCadence('manual')).toBe('manual');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('falls back to the coarsest exact duration', () => {
|
|
48
|
+
expect(formatCadence({ minutes: 360 })).toBe('6h');
|
|
49
|
+
expect(formatCadence({ minutes: 2880 })).toBe('2d');
|
|
50
|
+
expect(formatCadence({ minutes: 90 })).toBe('90m');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('the floor follows the sweep tick', () => {
|
|
55
|
+
test('each floor is the tick of the sweep that would serve it', () => {
|
|
56
|
+
expect(BACKUP_CADENCE_FLOOR_MINUTES).toBe(tickIntervalMinutes(BACKUP_SWEEP_PATTERN));
|
|
57
|
+
expect(MONITOR_INTERVAL_FLOOR_MINUTES).toBe(tickIntervalMinutes(ALERTING_SWEEP_PATTERN));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a finer tick admits a cadence the coarser one refused', () => {
|
|
61
|
+
const coarse = cadenceSchema({ floorMinutes: tickIntervalMinutes('timer.tick.1h') });
|
|
62
|
+
const fine = cadenceSchema({ floorMinutes: tickIntervalMinutes('timer.tick.5m') });
|
|
63
|
+
expect(coarse.safeParse('15m').success).toBe(false);
|
|
64
|
+
expect(fine.safeParse('15m').success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a pattern that is not a timer tick is a programming error, not a floor of zero', () => {
|
|
68
|
+
expect(() => tickIntervalMinutes('module.deployed')).toThrow();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('cadenceSchema', () => {
|
|
73
|
+
const schema = cadenceSchema({ floorMinutes: 60 });
|
|
74
|
+
|
|
75
|
+
test('accepts words, durations at or above the floor, and manual', () => {
|
|
76
|
+
for (const good of ['hourly', 'daily', 'weekly', 'monthly', 'manual', '6h', '2d', '60m']) {
|
|
77
|
+
expect(schema.safeParse(good).success).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('names the permitted forms when the value is unparseable', () => {
|
|
82
|
+
const result = schema.safeParse('dailyy');
|
|
83
|
+
expect(result.success).toBe(false);
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
expect(result.error.issues[0].message).toContain('named period');
|
|
86
|
+
expect(result.error.issues[0].message).toContain('manual');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('names the finest servable cadence when the value is below the floor', () => {
|
|
91
|
+
const result = schema.safeParse('5m');
|
|
92
|
+
expect(result.success).toBe(false);
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
expect(result.error.issues[0].message).toContain('hourly');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often celilo does something to a module, in one type.
|
|
3
|
+
*
|
|
4
|
+
* celilo has three per-module cadences — how often to back a module up, how
|
|
5
|
+
* often to health-check it, and (in the same family) how long to keep the
|
|
6
|
+
* backups. They were three spellings of the same idea: an enum of four words
|
|
7
|
+
* for backups, a `15m`-style duration for health checks, nothing shared. One
|
|
8
|
+
* namespace holding two unrelated formats is not one concept, so an operator
|
|
9
|
+
* setting `6h` on a backup had no way to be right.
|
|
10
|
+
*
|
|
11
|
+
* A cadence is therefore a word OR a duration, both normalised to minutes, plus
|
|
12
|
+
* `manual` for "the operator opted out". Every existing manifest keeps working:
|
|
13
|
+
* the words are the same words.
|
|
14
|
+
*
|
|
15
|
+
* The FLOOR is derived from the tick of the sweep that would act on the
|
|
16
|
+
* cadence, never stated independently. A cadence finer than its sweep's tick
|
|
17
|
+
* cannot be served, and accepting one leaves the operator believing they
|
|
18
|
+
* configured something that silently never happens. Deriving it means a changed
|
|
19
|
+
* tick moves the floor with it rather than waiting for someone to remember.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no database, no clock. See
|
|
22
|
+
* openspec/changes/operator-cadence-overrides/design.md D3, D4.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
|
|
27
|
+
/** Minutes between runs, or `manual` — the operator opted out entirely. */
|
|
28
|
+
export type Cadence = { minutes: number } | 'manual';
|
|
29
|
+
|
|
30
|
+
/** The words a cadence may be spelled with, and what each means in minutes. */
|
|
31
|
+
const NAMED_PERIODS: Record<string, number> = {
|
|
32
|
+
hourly: 60,
|
|
33
|
+
daily: 24 * 60,
|
|
34
|
+
weekly: 7 * 24 * 60,
|
|
35
|
+
// 30 days, matching what the backup schedule has always meant by `monthly`.
|
|
36
|
+
// No calendar-month semantics are introduced here, and none are lost.
|
|
37
|
+
monthly: 30 * 24 * 60,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const DURATION_PATTERN = /^(\d+)(m|h|d)$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Everything a cadence may be spelled as, for the JSON Schema export.
|
|
44
|
+
*
|
|
45
|
+
* Editors validate `modules/*/manifest.yml` against the exported JSON Schema,
|
|
46
|
+
* which cannot carry a Zod refinement — so well-formedness is duplicated as a
|
|
47
|
+
* regex the same way `health_check.interval` already does it. The floor is not
|
|
48
|
+
* expressible here and stays a refinement.
|
|
49
|
+
*/
|
|
50
|
+
export const CADENCE_PATTERN = /^(hourly|daily|weekly|monthly|manual|\d+(m|h|d))$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a duration string (`15m`, `1h`, `1d`) to whole minutes.
|
|
54
|
+
* Returns null when the string is not a well-formed duration.
|
|
55
|
+
*/
|
|
56
|
+
export function parseIntervalMinutes(value: string): number | null {
|
|
57
|
+
const match = DURATION_PATTERN.exec(value);
|
|
58
|
+
if (!match) return null;
|
|
59
|
+
const amount = Number.parseInt(match[1], 10);
|
|
60
|
+
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
61
|
+
const unit = match[2];
|
|
62
|
+
if (unit === 'm') return amount;
|
|
63
|
+
if (unit === 'h') return amount * 60;
|
|
64
|
+
return amount * 60 * 24;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parse a cadence in any accepted spelling. Returns null when malformed. */
|
|
68
|
+
export function parseCadence(value: string): Cadence | null {
|
|
69
|
+
if (value === 'manual') return 'manual';
|
|
70
|
+
const named = NAMED_PERIODS[value];
|
|
71
|
+
if (named !== undefined) return { minutes: named };
|
|
72
|
+
const minutes = parseIntervalMinutes(value);
|
|
73
|
+
return minutes === null ? null : { minutes };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Spell a cadence back the way an operator would write it, preferring the word
|
|
78
|
+
* when one exists — a resolved `1440` reads as `daily`, not `24h`.
|
|
79
|
+
*/
|
|
80
|
+
export function formatCadence(cadence: Cadence): string {
|
|
81
|
+
if (cadence === 'manual') return 'manual';
|
|
82
|
+
for (const [word, minutes] of Object.entries(NAMED_PERIODS)) {
|
|
83
|
+
if (minutes === cadence.minutes) return word;
|
|
84
|
+
}
|
|
85
|
+
if (cadence.minutes % (24 * 60) === 0) return `${cadence.minutes / (24 * 60)}d`;
|
|
86
|
+
if (cadence.minutes % 60 === 0) return `${cadence.minutes / 60}h`;
|
|
87
|
+
return `${cadence.minutes}m`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Milliseconds between runs. `manual` is infinite — never due, never stale. */
|
|
91
|
+
export function cadenceMs(cadence: Cadence): number {
|
|
92
|
+
return cadence === 'manual' ? Number.POSITIVE_INFINITY : cadence.minutes * 60_000;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Minutes between ticks of a bus timer pattern (`timer.tick.5m` → 5).
|
|
97
|
+
*
|
|
98
|
+
* The floors below are derived through this rather than written down, so
|
|
99
|
+
* changing which tick a sweep rides changes what it can serve in the same edit.
|
|
100
|
+
*/
|
|
101
|
+
export function tickIntervalMinutes(pattern: string): number {
|
|
102
|
+
const suffix = pattern.replace(/^timer\.tick\./, '');
|
|
103
|
+
const minutes = parseIntervalMinutes(suffix);
|
|
104
|
+
if (minutes === null) throw new Error(`Not a timer tick pattern: ${pattern}`);
|
|
105
|
+
return minutes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The bus tick each sweep rides.
|
|
110
|
+
*
|
|
111
|
+
* They live here, with the floors that derive from them, rather than beside
|
|
112
|
+
* each sweep's subscriber registration — a floor stated in one file and a tick
|
|
113
|
+
* chosen in another is exactly the pair that drifts. The sweeps import their
|
|
114
|
+
* pattern from here.
|
|
115
|
+
*/
|
|
116
|
+
export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
|
|
117
|
+
export const ALERTING_SWEEP_PATTERN = 'timer.tick.5m';
|
|
118
|
+
|
|
119
|
+
/** Finest backup cadence the hourly backup sweep can serve. */
|
|
120
|
+
export const BACKUP_CADENCE_FLOOR_MINUTES = tickIntervalMinutes(BACKUP_SWEEP_PATTERN);
|
|
121
|
+
|
|
122
|
+
/** Finest health-check cadence the five-minute alerting sweep can serve. */
|
|
123
|
+
export const MONITOR_INTERVAL_FLOOR_MINUTES = tickIntervalMinutes(ALERTING_SWEEP_PATTERN);
|
|
124
|
+
|
|
125
|
+
/** Human list of accepted spellings, naming the finest cadence this sweep serves. */
|
|
126
|
+
export function describeCadenceForm(floorMinutes: number): string {
|
|
127
|
+
return `Allowed: a named period (hourly, daily, weekly, monthly), a duration like "6h", "90m" or "3d" no finer than ${formatCadence({ minutes: floorMinutes })}, or "manual" to opt out.`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A cadence value that the named sweep can actually serve.
|
|
132
|
+
*
|
|
133
|
+
* Validates the string form (what an operator types and what a manifest
|
|
134
|
+
* carries); the caller parses it with `parseCadence` once it is known good.
|
|
135
|
+
*
|
|
136
|
+
* The well-formedness check is a `.regex` on the inner string rather than part
|
|
137
|
+
* of the refinement because only the regex survives the export to JSON Schema,
|
|
138
|
+
* and that export is what validates `modules/*/manifest.yml` in an editor. The
|
|
139
|
+
* floor cannot be expressed in JSON Schema at all, so it stays a refinement.
|
|
140
|
+
*/
|
|
141
|
+
export function cadenceSchema({
|
|
142
|
+
floorMinutes,
|
|
143
|
+
description,
|
|
144
|
+
}: {
|
|
145
|
+
floorMinutes: number;
|
|
146
|
+
description?: string;
|
|
147
|
+
}): z.ZodType<string> {
|
|
148
|
+
const form = describeCadenceForm(floorMinutes);
|
|
149
|
+
let base = z.string().regex(CADENCE_PATTERN, { message: form });
|
|
150
|
+
if (description) base = base.describe(description);
|
|
151
|
+
return base.superRefine((value, ctx) => {
|
|
152
|
+
const cadence = parseCadence(value);
|
|
153
|
+
if (cadence === null) {
|
|
154
|
+
// `0h` matches the pattern and is not a cadence.
|
|
155
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: form });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (cadence !== 'manual' && cadence.minutes < floorMinutes) {
|
|
159
|
+
ctx.addIssue({
|
|
160
|
+
code: z.ZodIssueCode.custom,
|
|
161
|
+
message: `"${value}" is finer than the sweep that would serve it can run (every ${formatCadence({ minutes: floorMinutes })}). ${form}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|