@celilo/cli 0.22.0 → 0.24.0
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 +61 -9
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +7 -7
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/alerts-sweep.ts +2 -0
- 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/events.ts +34 -3
- 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 +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +127 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +39 -9
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +104 -22
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/run-named-hook.ts +28 -0
- 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/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +52 -3
- package/src/services/alerting/sweep-runner.ts +41 -8
- 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/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/fleet-checks.ts +48 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +32 -49
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- package/src/test-utils/setup-test-db.ts +13 -23
|
@@ -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;
|
|
@@ -314,7 +314,7 @@ export async function busInterviewGuarded<TReply>(
|
|
|
314
314
|
* Ask a single generic interview question over the bus and return the
|
|
315
315
|
* responder's answer (ISS-0127). The generic counterpart to the deploy's
|
|
316
316
|
* config/secret/ensure interview: any operator command can call this to make
|
|
317
|
-
* its prompts headlessly drivable instead of
|
|
317
|
+
* its prompts headlessly drivable instead of prompting on stdin directly.
|
|
318
318
|
*
|
|
319
319
|
* The return type is `unknown` because the runtime shape depends on
|
|
320
320
|
* `payload.kind`; prefer the typed wrappers (`askText`, `askSelect`,
|
|
@@ -345,7 +345,7 @@ export async function askInterview(
|
|
|
345
345
|
*
|
|
346
346
|
* This is the shared lifecycle every migrated command wraps its interview in,
|
|
347
347
|
* so the start/close boilerplate lives in exactly one place. The dynamic
|
|
348
|
-
* import keeps
|
|
348
|
+
* import keeps the terminal renderer out of the non-TTY path's module graph.
|
|
349
349
|
*/
|
|
350
350
|
export async function withInterviewSession<T>(fn: () => Promise<T>): Promise<T> {
|
|
351
351
|
const responder = process.stdin.isTTY
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Drives `interviewForMissingSecrets` against a real sqlite bus + real
|
|
5
5
|
* encrypted store + a programmatic test responder. No fixture modules,
|
|
6
|
-
* no machines, no
|
|
6
|
+
* no machines, no terminal — the responder is just a `bus.watch` that
|
|
7
7
|
* mimics what `terminal-responder.ts` does.
|
|
8
8
|
*
|
|
9
9
|
* Covers what stage 3 introduced:
|
|
@@ -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
|
+
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
readInstalledUnit,
|
|
39
39
|
unitMainPid,
|
|
40
40
|
} from './events-daemon';
|
|
41
|
+
import { describePausedModule, listPausedModules } from './module-pause';
|
|
41
42
|
import { resolveSubscription } from './module-subscriptions';
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -854,6 +855,52 @@ export function checkControlPlaneNetwork(db: DbClient): FleetFinding {
|
|
|
854
855
|
};
|
|
855
856
|
}
|
|
856
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Any paused module is a doctor failure — no threshold, whatever its age
|
|
860
|
+
* (openspec/changes/module-pause-lifecycle, design D7, closed at review).
|
|
861
|
+
*
|
|
862
|
+
* A pause deliberately switches OFF the alerting that would otherwise report
|
|
863
|
+
* the module as down, so the paused-ness itself has to be the signal. A
|
|
864
|
+
* duration threshold was considered and dropped: there is no number of hours
|
|
865
|
+
* after which a deliberate outage becomes acceptable, and a configurable one is
|
|
866
|
+
* just an invitation to tune the detector until it stops firing.
|
|
867
|
+
*
|
|
868
|
+
* The fleet had just run 20 hours of failing forgejo backups whose only symptom
|
|
869
|
+
* was a column of `0 B` rows that read as healthy hourly cadence. Same failure
|
|
870
|
+
* shape; this is the check that would have named it.
|
|
871
|
+
*/
|
|
872
|
+
export function checkPausedModules(db: DbClient): FleetFinding {
|
|
873
|
+
const paused = listPausedModules(db);
|
|
874
|
+
|
|
875
|
+
if (paused.length === 0) {
|
|
876
|
+
return {
|
|
877
|
+
id: 'paused-modules',
|
|
878
|
+
title: 'No module is paused (a pause suppresses its own alerting)',
|
|
879
|
+
status: 'ok',
|
|
880
|
+
summary: 'nothing paused',
|
|
881
|
+
detail: [],
|
|
882
|
+
remediation: null,
|
|
883
|
+
autoFixable: false,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const names = paused.map((m) => describePausedModule(m));
|
|
888
|
+
return {
|
|
889
|
+
id: 'paused-modules',
|
|
890
|
+
title: 'No module is paused (a pause suppresses its own alerting)',
|
|
891
|
+
status: 'fail',
|
|
892
|
+
summary: `${paused.length} module(s) paused: ${paused.map((m) => m.id).join(', ')}`,
|
|
893
|
+
detail: [
|
|
894
|
+
...names.map((n) => `paused: ${n}`),
|
|
895
|
+
'a paused module receives no dispatched work, runs no health checks, and has its alerts suppressed',
|
|
896
|
+
'it is still deployed and may still be serving traffic — pause does not stop the data plane',
|
|
897
|
+
],
|
|
898
|
+
remediation:
|
|
899
|
+
'bring each back with "celilo module unpause <id>" (which redeploys it, rebinding its capabilities), or remove it if the pause was permanent',
|
|
900
|
+
autoFixable: false,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
857
904
|
export async function runFleetChecks(
|
|
858
905
|
bus: Bus,
|
|
859
906
|
db: DbClient,
|
|
@@ -865,6 +912,7 @@ export async function runFleetChecks(
|
|
|
865
912
|
checkSubscribers(bus, db),
|
|
866
913
|
checkCapabilityProviders(db),
|
|
867
914
|
checkControlPlaneNetwork(db),
|
|
915
|
+
checkPausedModules(db),
|
|
868
916
|
await checkServiceDns(db),
|
|
869
917
|
];
|
|
870
918
|
}
|
|
@@ -298,7 +298,15 @@ async function detectNetworkInterfacesWith(
|
|
|
298
298
|
interfaces.push({ name: iface.name, ipAddress: iface.ipAddress, zone });
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
// Classify: router if interfaces span multiple distinct zones
|
|
301
|
+
// Classify: router if interfaces span multiple distinct zones.
|
|
302
|
+
//
|
|
303
|
+
// The `!== 'unknown'` filter was dead code until now — `detectZoneFromIp`
|
|
304
|
+
// could not produce `'unknown'`, it claimed `external` instead, so every
|
|
305
|
+
// unmatched leg counted as a distinct zone and inflated this set. On the e2e
|
|
306
|
+
// firewall that meant four legs reported as `external`, collapsing to ONE
|
|
307
|
+
// zone here rather than the three real ones. The filter is live now, and it
|
|
308
|
+
// is the right rule: an interface celilo cannot attribute is not evidence of
|
|
309
|
+
// spanning anything.
|
|
302
310
|
const uniqueZones = new Set(interfaces.map((i) => i.zone).filter((z) => z !== 'unknown'));
|
|
303
311
|
const role: MachineRole = uniqueZones.size > 1 ? 'router' : 'host';
|
|
304
312
|
|
|
@@ -404,3 +412,17 @@ export async function testSshConnection(
|
|
|
404
412
|
return false;
|
|
405
413
|
}
|
|
406
414
|
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* How an interface's zone reads to an operator.
|
|
418
|
+
*
|
|
419
|
+
* `machine add` used to print the raw zone for every leg, which meant a firewall
|
|
420
|
+
* with five RFC1918 interfaces printed four of them as `(external)` — because
|
|
421
|
+
* `detectZoneFromIp` answered `external` when it meant "no idea". The vocabulary
|
|
422
|
+
* now distinguishes the two: a zone name when celilo matched one, and
|
|
423
|
+
* `unaccounted for` when it did not, which is the honest answer and the one that
|
|
424
|
+
* tells an operator there is something to declare.
|
|
425
|
+
*/
|
|
426
|
+
export function describeInterfaceZone(iface: NetworkInterface): string {
|
|
427
|
+
return iface.zone === 'unknown' ? 'unaccounted for — no declared subnet contains it' : iface.zone;
|
|
428
|
+
}
|