@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
|
@@ -116,7 +116,7 @@ describe('failingKeysFromFindings', () => {
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
describe('healthCoverageFailingKeys', () => {
|
|
119
|
-
const base = { hasHealthCheckHook: true,
|
|
119
|
+
const base = { hasHealthCheckHook: true, cadence: { minutes: 15 } } as const;
|
|
120
120
|
|
|
121
121
|
test('a monitored module produces no finding', () => {
|
|
122
122
|
expect(healthCoverageFailingKeys([{ id: 'caddy', state: 'VERIFIED', ...base }])).toEqual([]);
|
|
@@ -124,16 +124,26 @@ describe('healthCoverageFailingKeys', () => {
|
|
|
124
124
|
|
|
125
125
|
test('a module with no health_check hook is surfaced', () => {
|
|
126
126
|
const keys = healthCoverageFailingKeys([
|
|
127
|
-
{ id: 'signal', state: 'INSTALLED', hasHealthCheckHook: false,
|
|
127
|
+
{ id: 'signal', state: 'INSTALLED', hasHealthCheckHook: false, cadence: null },
|
|
128
128
|
]);
|
|
129
129
|
expect(keys).toHaveLength(1);
|
|
130
130
|
expect(keys[0].key).toBe('builtin:health_coverage/module:signal');
|
|
131
131
|
expect(keys[0].message).toContain('no health_check hook');
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
test('
|
|
134
|
+
test('an operator opt-out raises no finding — it is a decision, not a gap', () => {
|
|
135
|
+
// The finding would ask for the action they just declined, so nothing they
|
|
136
|
+
// could do would ever clear it.
|
|
137
|
+
expect(
|
|
138
|
+
healthCoverageFailingKeys([
|
|
139
|
+
{ id: 'lunacycle', state: 'VERIFIED', hasHealthCheckHook: true, cadence: 'manual' },
|
|
140
|
+
]),
|
|
141
|
+
).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a module with a hook but no cadence is surfaced differently', () => {
|
|
135
145
|
const keys = healthCoverageFailingKeys([
|
|
136
|
-
{ id: 'caddy', state: 'VERIFIED', hasHealthCheckHook: true,
|
|
146
|
+
{ id: 'caddy', state: 'VERIFIED', hasHealthCheckHook: true, cadence: null },
|
|
137
147
|
]);
|
|
138
148
|
expect(keys).toHaveLength(1);
|
|
139
149
|
expect(keys[0].message).toContain('nothing schedules it');
|
|
@@ -142,7 +152,7 @@ describe('healthCoverageFailingKeys', () => {
|
|
|
142
152
|
// A coverage gap is real but is not an outage — it must never page.
|
|
143
153
|
test('findings are warning severity', () => {
|
|
144
154
|
const keys = healthCoverageFailingKeys([
|
|
145
|
-
{ id: 'caddy', state: 'VERIFIED', hasHealthCheckHook: false,
|
|
155
|
+
{ id: 'caddy', state: 'VERIFIED', hasHealthCheckHook: false, cadence: null },
|
|
146
156
|
]);
|
|
147
157
|
expect(keys[0].severity).toBe('warning');
|
|
148
158
|
});
|
|
@@ -153,9 +163,7 @@ describe('healthCoverageFailingKeys', () => {
|
|
|
153
163
|
'ignores a module in state %s',
|
|
154
164
|
(state) => {
|
|
155
165
|
expect(
|
|
156
|
-
healthCoverageFailingKeys([
|
|
157
|
-
{ id: 'x', state, hasHealthCheckHook: false, hasEnabledMonitor: false },
|
|
158
|
-
]),
|
|
166
|
+
healthCoverageFailingKeys([{ id: 'x', state, hasHealthCheckHook: false, cadence: null }]),
|
|
159
167
|
).toEqual([]);
|
|
160
168
|
},
|
|
161
169
|
);
|
|
@@ -163,8 +171,8 @@ describe('healthCoverageFailingKeys', () => {
|
|
|
163
171
|
test('reports only the modules that are actually uncovered', () => {
|
|
164
172
|
const keys = healthCoverageFailingKeys([
|
|
165
173
|
{ id: 'caddy', state: 'VERIFIED', ...base },
|
|
166
|
-
{ id: 'signal', state: 'INSTALLED', hasHealthCheckHook: false,
|
|
167
|
-
{ id: 'forgejo', state: 'INSTALLED', hasHealthCheckHook: true,
|
|
174
|
+
{ id: 'signal', state: 'INSTALLED', hasHealthCheckHook: false, cadence: null },
|
|
175
|
+
{ id: 'forgejo', state: 'INSTALLED', hasHealthCheckHook: true, cadence: null },
|
|
168
176
|
]);
|
|
169
177
|
expect(keys.map((k) => k.key)).toEqual([
|
|
170
178
|
'builtin:health_coverage/module:signal',
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The migrate step, and the invariant it protects.
|
|
3
|
+
*
|
|
4
|
+
* A `module_hook` monitor's stored `intervalMinutes` and `enabled` are no
|
|
5
|
+
* longer read: cadence resolves from `module_configs`. That is only safe on an
|
|
6
|
+
* existing fleet because `celilo system migrate` — which the `.deb` postinst
|
|
7
|
+
* runs on every apt upgrade — carries the diverging rows over first. Without
|
|
8
|
+
* it, the upgrade that shipped this change would silently revert every
|
|
9
|
+
* operator's hand-set cadence and resume watching modules they had disabled.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { closeDb, getDb } from '../../db/client';
|
|
17
|
+
import { runMigrations } from '../../db/migrate';
|
|
18
|
+
import { modules, monitors } from '../../db/schema';
|
|
19
|
+
import { getModuleConfigValue, upsertModuleConfig } from '../module-config';
|
|
20
|
+
import { migrateMonitorCadences } from './cadence-migration';
|
|
21
|
+
import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, loadModuleHealthCadences } from './health-cadence';
|
|
22
|
+
|
|
23
|
+
function addModule(id: string, interval?: string): void {
|
|
24
|
+
getDb()
|
|
25
|
+
.insert(modules)
|
|
26
|
+
.values({
|
|
27
|
+
id,
|
|
28
|
+
name: id,
|
|
29
|
+
sourcePath: `/tmp/${id}`,
|
|
30
|
+
version: '1.0.0',
|
|
31
|
+
state: 'INSTALLED',
|
|
32
|
+
manifestData: {
|
|
33
|
+
id,
|
|
34
|
+
hooks: { health_check: { script: './health.ts', ...(interval ? { interval } : {}) } },
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
.run();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addMonitor(target: string, intervalMinutes: number, enabled = true): void {
|
|
41
|
+
getDb()
|
|
42
|
+
.insert(monitors)
|
|
43
|
+
.values({
|
|
44
|
+
id: `monitor-${target}`,
|
|
45
|
+
kind: 'module_hook',
|
|
46
|
+
target,
|
|
47
|
+
intervalMinutes,
|
|
48
|
+
enabled,
|
|
49
|
+
})
|
|
50
|
+
.run();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function override(moduleId: string): string | undefined {
|
|
54
|
+
const row = getModuleConfigValue(moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, getDb());
|
|
55
|
+
return row === null ? undefined : String(row.value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('migrateMonitorCadences', () => {
|
|
59
|
+
let dir: string;
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cadence-migration-'));
|
|
63
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
64
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
closeDb();
|
|
69
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a cadence that diverges from the manifest is carried over, so the upgrade can't revert it", () => {
|
|
74
|
+
addModule('caddy', '15m');
|
|
75
|
+
addMonitor('caddy', 60); // an operator re-cadenced this to hourly
|
|
76
|
+
|
|
77
|
+
const report = migrateMonitorCadences(getDb());
|
|
78
|
+
|
|
79
|
+
expect(override('caddy')).toBe('hourly');
|
|
80
|
+
expect(report.written.get('caddy')).toBe('hourly');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a disabled monitor becomes manual, so a module nobody wanted watched stays unwatched', () => {
|
|
84
|
+
addModule('lunacycle', '15m');
|
|
85
|
+
addMonitor('lunacycle', 15, false);
|
|
86
|
+
|
|
87
|
+
migrateMonitorCadences(getDb());
|
|
88
|
+
|
|
89
|
+
expect(override('lunacycle')).toBe('manual');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a cadence matching the manifest writes nothing — it must stay free to follow corrections', () => {
|
|
93
|
+
addModule('forgejo', '15m');
|
|
94
|
+
addMonitor('forgejo', 15);
|
|
95
|
+
|
|
96
|
+
const report = migrateMonitorCadences(getDb());
|
|
97
|
+
|
|
98
|
+
expect(override('forgejo')).toBeUndefined();
|
|
99
|
+
expect(report.unchanged).toContain('forgejo');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('an existing override is never overwritten', () => {
|
|
103
|
+
addModule('authentik', '15m');
|
|
104
|
+
addMonitor('authentik', 60);
|
|
105
|
+
upsertModuleConfig(getDb(), 'authentik', HEALTH_CHECK_INTERVAL_CONFIG_KEY, 'daily');
|
|
106
|
+
|
|
107
|
+
migrateMonitorCadences(getDb());
|
|
108
|
+
|
|
109
|
+
expect(override('authentik')).toBe('daily');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a second run changes nothing', () => {
|
|
113
|
+
addModule('caddy', '15m');
|
|
114
|
+
addMonitor('caddy', 60);
|
|
115
|
+
|
|
116
|
+
migrateMonitorCadences(getDb());
|
|
117
|
+
const second = migrateMonitorCadences(getDb());
|
|
118
|
+
|
|
119
|
+
expect(second.written.size).toBe(0);
|
|
120
|
+
expect(override('caddy')).toBe('hourly');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("a module_hook row's stored cadence is not consulted", () => {
|
|
125
|
+
let dir: string;
|
|
126
|
+
|
|
127
|
+
beforeEach(async () => {
|
|
128
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cadence-row-'));
|
|
129
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
130
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
closeDb();
|
|
135
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
136
|
+
rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A future reader that reintroduces the dependency on the row fails here
|
|
140
|
+
// rather than silently regressing to write-time resolution (design.md D8).
|
|
141
|
+
test('the resolved cadence follows the manifest, not the row', () => {
|
|
142
|
+
addModule('caddy', '15m');
|
|
143
|
+
addMonitor('caddy', 9999);
|
|
144
|
+
|
|
145
|
+
expect(loadModuleHealthCadences(getDb()).get('caddy')?.cadence).toEqual({ minutes: 15 });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('the resolved cadence follows the override, not the row', () => {
|
|
149
|
+
addModule('caddy', '15m');
|
|
150
|
+
addMonitor('caddy', 9999);
|
|
151
|
+
upsertModuleConfig(getDb(), 'caddy', HEALTH_CHECK_INTERVAL_CONFIG_KEY, '1h');
|
|
152
|
+
|
|
153
|
+
expect(loadModuleHealthCadences(getDb()).get('caddy')?.cadence).toEqual({ minutes: 60 });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrying an existing fleet's health-check cadences into `module_configs`.
|
|
3
|
+
*
|
|
4
|
+
* Not bookkeeping. Before this change a `module_hook` monitor's cadence lived
|
|
5
|
+
* on its row, seeded from the manifest at first deploy and editable only by raw
|
|
6
|
+
* SQL; after it, the row is not read and the cadence comes from the operator's
|
|
7
|
+
* override or the manifest's suggestion. So on the FIRST apt upgrade that
|
|
8
|
+
* carries this code, every module whose row diverges from its manifest — which
|
|
9
|
+
* is every module anyone ever re-cadenced by hand — would silently revert to
|
|
10
|
+
* the author's suggestion, and every module an operator deliberately disabled
|
|
11
|
+
* would start being watched again.
|
|
12
|
+
*
|
|
13
|
+
* Two steps, both idempotent, both writing only where no override exists:
|
|
14
|
+
*
|
|
15
|
+
* 1. a row whose cadence differs from what its manifest suggests gets that
|
|
16
|
+
* cadence written as an override;
|
|
17
|
+
* 2. a disabled row gets `manual`.
|
|
18
|
+
*
|
|
19
|
+
* Runs from `celilo system migrate`, which the `.deb` postinst already invokes
|
|
20
|
+
* on every upgrade. Rolling back is safe in both directions: the rows written
|
|
21
|
+
* here are inert to older code, which reads the monitor row, and rolling
|
|
22
|
+
* forward again finds them already present.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { eq } from 'drizzle-orm';
|
|
26
|
+
import type { DbClient } from '../../db/client';
|
|
27
|
+
import { modules, monitors } from '../../db/schema';
|
|
28
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
29
|
+
import { formatCadence, parseCadence } from '../cadence';
|
|
30
|
+
import { getModuleConfigValue, upsertModuleConfig } from '../module-config';
|
|
31
|
+
import { HEALTH_CHECK_INTERVAL_CONFIG_KEY } from './health-cadence';
|
|
32
|
+
|
|
33
|
+
export interface CadenceMigrationReport {
|
|
34
|
+
/** `moduleId → written value`, for the operator-visible summary. */
|
|
35
|
+
written: Map<string, string>;
|
|
36
|
+
/** Modules left alone: an override already existed, or nothing diverged. */
|
|
37
|
+
unchanged: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function migrateMonitorCadences(db: DbClient): CadenceMigrationReport {
|
|
41
|
+
const report: CadenceMigrationReport = { written: new Map(), unchanged: [] };
|
|
42
|
+
|
|
43
|
+
for (const monitor of db.select().from(monitors).where(eq(monitors.kind, 'module_hook')).all()) {
|
|
44
|
+
const moduleId = monitor.target;
|
|
45
|
+
|
|
46
|
+
// An operator who has already set one has said the last word; never
|
|
47
|
+
// overwrite it, which is also what makes a second run a no-op.
|
|
48
|
+
if (getModuleConfigValue(moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, db)) {
|
|
49
|
+
report.unchanged.push(moduleId);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
54
|
+
if (!module) {
|
|
55
|
+
// A monitor whose module is gone has nothing to carry over.
|
|
56
|
+
report.unchanged.push(moduleId);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Disabled wins over cadence: an operator who stopped watching a module
|
|
61
|
+
// meant that, whatever interval the row happens to carry.
|
|
62
|
+
if (!monitor.enabled) {
|
|
63
|
+
upsertModuleConfig(db, moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, 'manual');
|
|
64
|
+
report.written.set(moduleId, 'manual');
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const manifest = module.manifestData as ModuleManifest;
|
|
69
|
+
const suggested = manifest.hooks?.health_check?.interval;
|
|
70
|
+
const suggestedMinutes = suggested ? parseCadence(suggested) : null;
|
|
71
|
+
const suggestedIsSame =
|
|
72
|
+
suggestedMinutes !== null &&
|
|
73
|
+
suggestedMinutes !== 'manual' &&
|
|
74
|
+
suggestedMinutes.minutes === monitor.intervalMinutes;
|
|
75
|
+
|
|
76
|
+
if (suggestedIsSame) {
|
|
77
|
+
// The row never diverged, so read-time resolution already produces it —
|
|
78
|
+
// and writing an override here would freeze this module against every
|
|
79
|
+
// future manifest correction, which is the failure this change removes.
|
|
80
|
+
report.unchanged.push(moduleId);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const carried = formatCadence({ minutes: monitor.intervalMinutes });
|
|
85
|
+
upsertModuleConfig(db, moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, carried);
|
|
86
|
+
report.written.set(moduleId, carried);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return report;
|
|
90
|
+
}
|
|
@@ -6,21 +6,18 @@
|
|
|
6
6
|
* lives here. See Rule 2.3.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { eq } from 'drizzle-orm';
|
|
10
9
|
import type { DbClient } from '../../db/client';
|
|
11
|
-
import { modules
|
|
10
|
+
import { modules } from '../../db/schema';
|
|
12
11
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
12
|
+
import { loadModuleHealthCadences } from './health-cadence';
|
|
13
13
|
import type { ModuleCoverageInput } from './health-coverage';
|
|
14
14
|
|
|
15
15
|
export function loadModuleCoverage(db: DbClient): ModuleCoverageInput[] {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.all()
|
|
22
|
-
.map((row) => row.target),
|
|
23
|
-
);
|
|
16
|
+
// Coverage now asks the same question the sweep asks — "what is this module's
|
|
17
|
+
// effective cadence" — rather than "does an enabled monitor row exist". The
|
|
18
|
+
// row's `enabled` column is `builtin_check`-only, so reading it here would
|
|
19
|
+
// have reported a module as watched that the sweep had stopped scheduling.
|
|
20
|
+
const cadences = loadModuleHealthCadences(db);
|
|
24
21
|
|
|
25
22
|
return db
|
|
26
23
|
.select({ id: modules.id, state: modules.state, manifestData: modules.manifestData })
|
|
@@ -32,7 +29,7 @@ export function loadModuleCoverage(db: DbClient): ModuleCoverageInput[] {
|
|
|
32
29
|
id: module.id,
|
|
33
30
|
state: module.state,
|
|
34
31
|
hasHealthCheckHook: Boolean(manifest.hooks?.health_check),
|
|
35
|
-
|
|
32
|
+
cadence: cadences.get(module.id)?.cadence ?? null,
|
|
36
33
|
};
|
|
37
34
|
});
|
|
38
35
|
}
|
|
@@ -101,21 +101,30 @@ describe('deploy hooks', () => {
|
|
|
101
101
|
expect(monitor.intervalMinutes).toBe(15);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
// The row carries severity, escalation policy and lastRunAt; WHETHER it
|
|
105
|
+
// runs comes from the module's effective cadence. So a module with the hook
|
|
106
|
+
// and no suggested interval still gets a row — otherwise an operator who
|
|
107
|
+
// sets `health_check_interval` on it would have nothing to schedule against.
|
|
108
|
+
test('a module with the hook but no interval still gets a row to carry its state', () => {
|
|
105
109
|
addModule('caddy', { hooks: { health_check: { script: 'x' } } });
|
|
106
|
-
expect(ensureMonitorOnDeploy(db, 'caddy')).toBe(
|
|
110
|
+
expect(ensureMonitorOnDeploy(db, 'caddy')).toBe(true);
|
|
111
|
+
expect(db.select().from(monitors).all()).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a module with no health_check hook at all gets nothing', () => {
|
|
115
|
+
addModule('signal', { hooks: {} });
|
|
116
|
+
expect(ensureMonitorOnDeploy(db, 'signal')).toBe(false);
|
|
107
117
|
expect(db.select().from(monitors).all()).toEqual([]);
|
|
108
118
|
});
|
|
109
119
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
test('
|
|
120
|
+
// Not because the row protects an operator's setting any more — that lives
|
|
121
|
+
// in module_configs now — but because a redeploy must not duplicate rows.
|
|
122
|
+
test('a second deploy does not create a second row', () => {
|
|
113
123
|
addModule('caddy', { hooks: { health_check: { script: 'x', interval: '15m' } } });
|
|
114
124
|
ensureMonitorOnDeploy(db, 'caddy');
|
|
115
|
-
db.update(monitors).set({ intervalMinutes: 60 }).run();
|
|
116
125
|
|
|
117
126
|
expect(ensureMonitorOnDeploy(db, 'caddy')).toBe(false);
|
|
118
|
-
expect(db.select().from(monitors).all()
|
|
127
|
+
expect(db.select().from(monitors).all()).toHaveLength(1);
|
|
119
128
|
});
|
|
120
129
|
|
|
121
130
|
test('an unknown module does not throw', () => {
|
|
@@ -92,10 +92,14 @@ export function modulesInDeployWindow(db: DbClient): Set<string> {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* Create
|
|
95
|
+
* Create the monitor row for a freshly deployed module that declares a
|
|
96
|
+
* `health_check` hook.
|
|
96
97
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
98
|
+
* A row for any such module, not only one whose manifest suggests an interval:
|
|
99
|
+
* the row carries severity, escalation policy and `lastRunAt`, while whether it
|
|
100
|
+
* runs comes from the module's effective cadence. Without a row, an operator
|
|
101
|
+
* setting `health_check_interval` on a module whose author named none would
|
|
102
|
+
* have nothing to schedule against.
|
|
99
103
|
*/
|
|
100
104
|
export function ensureMonitorOnDeploy(db: DbClient, moduleId: string): boolean {
|
|
101
105
|
try {
|
|
@@ -103,8 +107,10 @@ export function ensureMonitorOnDeploy(db: DbClient, moduleId: string): boolean {
|
|
|
103
107
|
if (!module) return false;
|
|
104
108
|
|
|
105
109
|
const manifest = module.manifestData as ModuleManifest;
|
|
106
|
-
const
|
|
107
|
-
|
|
110
|
+
const healthCheck = manifest.hooks?.health_check;
|
|
111
|
+
if (!healthCheck) return false;
|
|
112
|
+
|
|
113
|
+
return ensureMonitorForModule(db, moduleId, healthCheck.interval) !== null;
|
|
108
114
|
} catch {
|
|
109
115
|
return false;
|
|
110
116
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
3
|
+
import { effectiveHealthCheckCadence, isScheduled } from './health-cadence';
|
|
4
|
+
|
|
5
|
+
function manifestWith(interval?: string): ModuleManifest {
|
|
6
|
+
return {
|
|
7
|
+
hooks: { health_check: { script: './health.ts', ...(interval ? { interval } : {}) } },
|
|
8
|
+
} as unknown as ModuleManifest;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('effectiveHealthCheckCadence', () => {
|
|
12
|
+
test("the manifest's suggestion applies when nobody has overridden", () => {
|
|
13
|
+
expect(effectiveHealthCheckCadence(manifestWith('15m'), undefined)).toEqual({ minutes: 15 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('the operator override wins', () => {
|
|
17
|
+
expect(effectiveHealthCheckCadence(manifestWith('15m'), '1h')).toEqual({ minutes: 60 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The failure this whole change exists for: the cadence used to be seeded onto
|
|
21
|
+
// the monitor row at first deploy and never reconsulted, so an author who
|
|
22
|
+
// corrected a bad interval never reached an existing install.
|
|
23
|
+
test('a corrected suggestion reaches an un-overridden module', () => {
|
|
24
|
+
const before = effectiveHealthCheckCadence(manifestWith('1h'), undefined);
|
|
25
|
+
const afterUpgrade = effectiveHealthCheckCadence(manifestWith('15m'), undefined);
|
|
26
|
+
|
|
27
|
+
expect(before).toEqual({ minutes: 60 });
|
|
28
|
+
expect(afterUpgrade).toEqual({ minutes: 15 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a corrected suggestion does NOT disturb an overridden module', () => {
|
|
32
|
+
expect(effectiveHealthCheckCadence(manifestWith('15m'), '1h')).toEqual({ minutes: 60 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('`manual` stops it being scheduled, and is not the same as unset', () => {
|
|
36
|
+
expect(effectiveHealthCheckCadence(manifestWith('15m'), 'manual')).toBe('manual');
|
|
37
|
+
expect(isScheduled('manual')).toBe(false);
|
|
38
|
+
expect(isScheduled(null)).toBe(false);
|
|
39
|
+
expect(isScheduled({ minutes: 15 })).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('unsetting the override resumes the manifest cadence', () => {
|
|
43
|
+
// `undefined` is what the accessor sees once the row is deleted.
|
|
44
|
+
expect(effectiveHealthCheckCadence(manifestWith('15m'), undefined)).toEqual({ minutes: 15 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('nobody naming a cadence is null — a gap, not an opt-out', () => {
|
|
48
|
+
expect(effectiveHealthCheckCadence(manifestWith(), undefined)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an operator can set a cadence a module never suggested', () => {
|
|
52
|
+
expect(effectiveHealthCheckCadence(manifestWith(), 'daily')).toEqual({ minutes: 1440 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('an unparseable override falls back to the manifest rather than going quiet', () => {
|
|
56
|
+
expect(effectiveHealthCheckCadence(manifestWith('15m'), 'fifteen')).toEqual({ minutes: 15 });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often celilo health-checks a module.
|
|
3
|
+
*
|
|
4
|
+
* The same shape as the backup cadence, and for the same reason. The manifest's
|
|
5
|
+
* `hooks.health_check.interval` is the author's SUGGESTION; the operator's
|
|
6
|
+
* `health_check_interval` override decides; both resolve HERE, at read time.
|
|
7
|
+
*
|
|
8
|
+
* What this replaces is worth stating, because it failed silently in both
|
|
9
|
+
* directions. The cadence used to be seeded onto the `monitors` row at first
|
|
10
|
+
* deploy and never reconsulted (`monitors.ts` returned early if a row existed),
|
|
11
|
+
* so an author who corrected a bad interval never reached an existing install,
|
|
12
|
+
* with nothing an operator could read to discover it. And since nothing ever
|
|
13
|
+
* wrote `monitors.intervalMinutes` after that insert, an operator's only way to
|
|
14
|
+
* re-cadence a module was raw SQL against `celilo.db`.
|
|
15
|
+
*
|
|
16
|
+
* `manual` means the operator has stopped watching the module. That is a
|
|
17
|
+
* decision, not a gap: it raises no health-coverage finding, because a finding
|
|
18
|
+
* asking for the action they just declined is one no action can clear.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
import type { DbClient } from '../../db/client';
|
|
23
|
+
import { moduleConfigs, modules } from '../../db/schema';
|
|
24
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
25
|
+
import { type Cadence, parseCadence } from '../cadence';
|
|
26
|
+
import { getModuleConfigValue, parseStoredConfigValue } from '../module-config';
|
|
27
|
+
import { findMonitor, resolveMonitorAlerts } from './monitors';
|
|
28
|
+
|
|
29
|
+
/** The `module_configs` key an operator's health-check cadence is stored under. */
|
|
30
|
+
export const HEALTH_CHECK_INTERVAL_CONFIG_KEY = 'health_check_interval';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `null` means NOBODY has said how often — the module is unscheduled and that
|
|
34
|
+
* is a coverage gap. It is distinct from `'manual'`, which is an operator
|
|
35
|
+
* saying not to watch it. There is no default: an interval celilo invented
|
|
36
|
+
* would run someone's health hook on a cadence no one chose.
|
|
37
|
+
*
|
|
38
|
+
* An unparseable override falls back to the manifest, the same direction the
|
|
39
|
+
* backup cadence fails in — values are validated at SET time, so a bad one here
|
|
40
|
+
* means hand-edited state, and continuing to watch beats going quiet.
|
|
41
|
+
*/
|
|
42
|
+
export function effectiveHealthCheckCadence(
|
|
43
|
+
manifest: ModuleManifest,
|
|
44
|
+
override: string | undefined,
|
|
45
|
+
): Cadence | null {
|
|
46
|
+
if (override !== undefined) {
|
|
47
|
+
const chosen = parseCadence(override);
|
|
48
|
+
if (chosen !== null) return chosen;
|
|
49
|
+
}
|
|
50
|
+
const suggested = manifest.hooks?.health_check?.interval;
|
|
51
|
+
if (suggested !== undefined) {
|
|
52
|
+
const parsed = parseCadence(suggested);
|
|
53
|
+
if (parsed !== null) return parsed;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a resolved cadence schedules anything at all. */
|
|
59
|
+
export function isScheduled(cadence: Cadence | null): cadence is { minutes: number } {
|
|
60
|
+
return cadence !== null && cadence !== 'manual';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ModuleHealthCadence {
|
|
64
|
+
moduleId: string;
|
|
65
|
+
manifest: ModuleManifest;
|
|
66
|
+
state: string;
|
|
67
|
+
cadence: Cadence | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every module's effective health-check cadence, in one pass.
|
|
72
|
+
*
|
|
73
|
+
* Read here rather than resolved per call site (Rule 2.3): the alerting sweep,
|
|
74
|
+
* the coverage check and `monitor list` all need the same answer, and three
|
|
75
|
+
* queries that could disagree is the shape this change exists to remove.
|
|
76
|
+
*/
|
|
77
|
+
export function loadModuleHealthCadences(db: DbClient): Map<string, ModuleHealthCadence> {
|
|
78
|
+
const overrides = new Map<string, string>();
|
|
79
|
+
for (const row of db
|
|
80
|
+
.select()
|
|
81
|
+
.from(moduleConfigs)
|
|
82
|
+
.where(eq(moduleConfigs.key, HEALTH_CHECK_INTERVAL_CONFIG_KEY))
|
|
83
|
+
.all()) {
|
|
84
|
+
overrides.set(row.moduleId, String(parseStoredConfigValue(row)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cadences = new Map<string, ModuleHealthCadence>();
|
|
88
|
+
for (const module of db.select().from(modules).all()) {
|
|
89
|
+
const manifest = module.manifestData as ModuleManifest;
|
|
90
|
+
cadences.set(module.id, {
|
|
91
|
+
moduleId: module.id,
|
|
92
|
+
manifest,
|
|
93
|
+
state: module.state,
|
|
94
|
+
cadence: effectiveHealthCheckCadence(manifest, overrides.get(module.id)),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return cadences;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Bring a module's watch state into line with its effective cadence.
|
|
102
|
+
*
|
|
103
|
+
* Only one direction needs doing: a module that has just become `manual` may
|
|
104
|
+
* still own live alerts from its last scheduled runs, and nothing will ever
|
|
105
|
+
* report on them again — they would sit firing forever with no operator action
|
|
106
|
+
* able to clear them. This resolves them, the way disabling a monitor always
|
|
107
|
+
* has.
|
|
108
|
+
*
|
|
109
|
+
* Idempotent, and safe to call when nothing changed. Called wherever a
|
|
110
|
+
* `health_check_interval` override is written: `module config set`/`unset`, and
|
|
111
|
+
* the migrate step.
|
|
112
|
+
*/
|
|
113
|
+
export function reconcileModuleWatchState(db: DbClient, moduleId: string, now: Date): number {
|
|
114
|
+
const monitor = findMonitor(db, 'module_hook', moduleId);
|
|
115
|
+
if (!monitor) return 0;
|
|
116
|
+
|
|
117
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
118
|
+
if (!module) return 0;
|
|
119
|
+
|
|
120
|
+
const override = getModuleConfigValue(moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, db);
|
|
121
|
+
const cadence = effectiveHealthCheckCadence(
|
|
122
|
+
module.manifestData as ModuleManifest,
|
|
123
|
+
override === null ? undefined : String(override.value),
|
|
124
|
+
);
|
|
125
|
+
if (isScheduled(cadence)) return 0;
|
|
126
|
+
|
|
127
|
+
return resolveMonitorAlerts(db, monitor.id, now);
|
|
128
|
+
}
|
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two ways that happens, and both are worth saying out loud:
|
|
12
12
|
* - the module ships no `health_check` hook, so it can never be verified;
|
|
13
|
-
* - it has one, but
|
|
13
|
+
* - it has one, but nothing gives it a cadence, so nothing schedules it.
|
|
14
|
+
*
|
|
15
|
+
* An effective cadence of `manual` raises NOTHING. The check exists to catch
|
|
16
|
+
* blindness nobody chose; `manual` is a recorded decision, visible in `module
|
|
17
|
+
* status` and removable with one command. A finding raised by an operator's
|
|
18
|
+
* explicit opt-out asks for the action they just declined, so no action of
|
|
19
|
+
* theirs could ever clear it.
|
|
14
20
|
*
|
|
15
21
|
* Deliberately cheap: this reads the module roster against the monitor set and
|
|
16
22
|
* contacts nothing. It is safe to run on every sweep.
|
|
@@ -19,6 +25,8 @@
|
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
27
|
import type { ModuleState } from '../../db/schema';
|
|
28
|
+
import type { Cadence } from '../cadence';
|
|
29
|
+
import { isScheduled } from './health-cadence';
|
|
22
30
|
import { type FailingKey, builtinAlertKey } from './keys';
|
|
23
31
|
|
|
24
32
|
export const HEALTH_COVERAGE_CHECK = 'health_coverage';
|
|
@@ -35,8 +43,11 @@ export interface ModuleCoverageInput {
|
|
|
35
43
|
state: ModuleState;
|
|
36
44
|
/** Whether the manifest declares a `health_check` hook at all. */
|
|
37
45
|
hasHealthCheckHook: boolean;
|
|
38
|
-
/**
|
|
39
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The module's effective health-check cadence: `null` when nobody has named
|
|
48
|
+
* one (a gap), `'manual'` when the operator opted out (a decision).
|
|
49
|
+
*/
|
|
50
|
+
cadence: Cadence | null;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
/**
|
|
@@ -65,15 +76,14 @@ export function healthCoverageFailingKeys(modules: ModuleCoverageInput[]): Faili
|
|
|
65
76
|
continue;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
if (
|
|
79
|
+
if (module.cadence === 'manual') continue;
|
|
80
|
+
|
|
81
|
+
if (!isScheduled(module.cadence)) {
|
|
69
82
|
failing.push({
|
|
70
83
|
key,
|
|
71
84
|
severity: 'warning',
|
|
72
85
|
message: `${module.id} has a health_check hook but nothing schedules it`,
|
|
73
|
-
details:
|
|
74
|
-
'The module can be verified on demand but is not being watched.\n' +
|
|
75
|
-
'Create a monitor for it, or set hooks.health_check.interval in the\n' +
|
|
76
|
-
'module manifest so one is created on deploy.',
|
|
86
|
+
details: `The module can be verified on demand but is not being watched.\nGive it a cadence: celilo module config set ${module.id} health_check_interval 15m\nor set hooks.health_check.interval in the module manifest.`,
|
|
77
87
|
});
|
|
78
88
|
}
|
|
79
89
|
}
|