@celilo/cli 0.21.0 → 0.23.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 +5 -4
- package/CELILO_SUBSYSTEMS.md +34 -2
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -6
- 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-sweep.ts +2 -0
- package/src/cli/commands/events.test.ts +66 -0
- package/src/cli/commands/events.ts +106 -3
- 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 +3 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/completion.ts +5 -0
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +49 -20
- 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/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/run-named-hook.ts +28 -0
- 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 +1 -0
- package/src/services/alerting/sweep-runner.ts +11 -1
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/dns-registrations.ts +12 -0
- package/src/services/fleet-checks.test.ts +46 -0
- package/src/services/fleet-checks.ts +63 -6
- 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/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.ts +2 -39
- 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
|
@@ -42,6 +42,19 @@ function seedHeartbeat(
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Abandon `count` deliveries to one subscriber, oldest first. */
|
|
46
|
+
function seedFailed(bus: Bus, count: number): void {
|
|
47
|
+
const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.refresh', handler: 'echo' });
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const event = bus.emitRaw('ddns.refresh', { n: i });
|
|
50
|
+
bus.markFailed(
|
|
51
|
+
{ eventId: event.id, subscriberId: sub.id },
|
|
52
|
+
new Error('handler timed out after 30000ms'),
|
|
53
|
+
{ abandoned: true },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
|
|
46
59
|
function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
|
|
47
60
|
const path = getDaemonUnitPath('linux', home, scope, systemRoot);
|
|
@@ -199,6 +212,39 @@ describe('checkDispatcher', () => {
|
|
|
199
212
|
expect(f.status).toBe('warn');
|
|
200
213
|
expect(f.detail.join(' ')).toContain('not emitting on schedule');
|
|
201
214
|
});
|
|
215
|
+
|
|
216
|
+
// celilo#623 — the old check read `failedDeliveries({ limit: 50 }).length`,
|
|
217
|
+
// so on celilo-mgr it printed a literal `50` that meant "at least 50" and
|
|
218
|
+
// read as an exact count. 137 > any limit anyone would pick.
|
|
219
|
+
it('reports the TRUE total of failed deliveries, not the read limit', () => {
|
|
220
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
221
|
+
installFakeUnit(home);
|
|
222
|
+
seedFailed(bus, 137);
|
|
223
|
+
|
|
224
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
225
|
+
expect(f.status).toBe('warn');
|
|
226
|
+
expect(f.detail.join(' ')).toContain('137 failed/abandoned delivery(ies) total');
|
|
227
|
+
expect(f.detail.join(' ')).not.toContain('50 failed');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The stored error is double-wrapped for every row written before the
|
|
231
|
+
// serializeError fix; those live 90 days, so doctor must unwrap them.
|
|
232
|
+
it('renders the sample error text, not {"message":"[object Object]"}', () => {
|
|
233
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
234
|
+
installFakeUnit(home);
|
|
235
|
+
seedFailed(bus, 1);
|
|
236
|
+
bus.db.run('UPDATE deliveries SET last_error = ?', [
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
message: '[object Object]',
|
|
239
|
+
value: { message: 'handler exited with code 1' },
|
|
240
|
+
}),
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
244
|
+
const detail = f.detail.join(' ');
|
|
245
|
+
expect(detail).toContain('handler exited with code 1');
|
|
246
|
+
expect(detail).not.toContain('[object Object]');
|
|
247
|
+
});
|
|
202
248
|
});
|
|
203
249
|
|
|
204
250
|
describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* rendering + `--fix` orchestration lives in the doctor command.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type
|
|
20
|
+
import { type Bus, describeError } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
22
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
23
|
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
@@ -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
|
/**
|
|
@@ -343,12 +344,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
343
344
|
}
|
|
344
345
|
}
|
|
345
346
|
|
|
346
|
-
|
|
347
|
-
|
|
347
|
+
// A TRUE total, not `failedDeliveries().length` — that saturates at its LIMIT
|
|
348
|
+
// and printed a literal `50` on celilo-mgr that read as a count (celilo#623).
|
|
349
|
+
// The newest row dates the backlog: a big total whose newest entry is days old
|
|
350
|
+
// is drained history, not active bleeding.
|
|
351
|
+
const { total: failedTotal } = bus.failedDeliveryTotals();
|
|
352
|
+
if (failedTotal > 0) {
|
|
348
353
|
statuses.push('warn');
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
354
|
+
const newest = bus.failedDeliveries({ limit: 1 })[0];
|
|
355
|
+
const age = newest?.finishedAt
|
|
356
|
+
? `, most recent ${Math.round((now - newest.finishedAt) / 60000)}min ago`
|
|
357
|
+
: '';
|
|
358
|
+
detail.push(`${failedTotal} failed/abandoned delivery(ies) total${age}`);
|
|
359
|
+
const sample = describeError(newest?.lastError ?? null)?.split('\n')[0];
|
|
360
|
+
if (sample) detail.push(` example (newest, not the only one): ${sample}`);
|
|
361
|
+
remediations.push('`celilo events list-failed` to see them; re-emit/repair as needed');
|
|
352
362
|
}
|
|
353
363
|
|
|
354
364
|
const status = worst(statuses);
|
|
@@ -845,6 +855,52 @@ export function checkControlPlaneNetwork(db: DbClient): FleetFinding {
|
|
|
845
855
|
};
|
|
846
856
|
}
|
|
847
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
|
+
|
|
848
904
|
export async function runFleetChecks(
|
|
849
905
|
bus: Bus,
|
|
850
906
|
db: DbClient,
|
|
@@ -856,6 +912,7 @@ export async function runFleetChecks(
|
|
|
856
912
|
checkSubscribers(bus, db),
|
|
857
913
|
checkCapabilityProviders(db),
|
|
858
914
|
checkControlPlaneNetwork(db),
|
|
915
|
+
checkPausedModules(db),
|
|
859
916
|
await checkServiceDns(db),
|
|
860
917
|
];
|
|
861
918
|
}
|
|
@@ -350,7 +350,7 @@ async function deployModuleImpl(
|
|
|
350
350
|
|
|
351
351
|
// Terminal-responder: when running on a TTY, this subscribes to
|
|
352
352
|
// `config.required.*` / `secret.required.*` / `ensure.required.*`
|
|
353
|
-
// events and prompts
|
|
353
|
+
// events and prompts on the terminal. Other responder shapes (Claude
|
|
354
354
|
// subagent, `celilo events respond` from another shell, autoresponder
|
|
355
355
|
// daemon) compete on the bus; first reply wins. See
|
|
356
356
|
// infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of pause that exist so a pause cannot become a silent outage
|
|
3
|
+
* (design D7), plus the alerting suppressor.
|
|
4
|
+
*
|
|
5
|
+
* Every check here is asserted in BOTH directions per Rule 7.6 — a gate nobody
|
|
6
|
+
* has seen fail is not a gate, and "no paused module was reported" is exactly
|
|
7
|
+
* the output a broken detector produces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { mkdtempSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
15
|
+
import { type ModuleState, modules } from '../db/schema';
|
|
16
|
+
import { moduleAlertKey } from './alerting/keys';
|
|
17
|
+
import { findSuppressor } from './alerting/suppression';
|
|
18
|
+
import { checkPausedModules } from './fleet-checks';
|
|
19
|
+
import {
|
|
20
|
+
describeMachineStopInfra,
|
|
21
|
+
describePausedModule,
|
|
22
|
+
listPausedModules,
|
|
23
|
+
pausedAmong,
|
|
24
|
+
} from './module-pause';
|
|
25
|
+
|
|
26
|
+
function makeDb(): DbClient {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-pause-obs-'));
|
|
28
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
29
|
+
return createDbClient({ path: process.env.CELILO_DB_PATH });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function insert(
|
|
33
|
+
db: DbClient,
|
|
34
|
+
id: string,
|
|
35
|
+
state: ModuleState,
|
|
36
|
+
opts: { pausedAt?: Date; reason?: string } = {},
|
|
37
|
+
): void {
|
|
38
|
+
db.insert(modules)
|
|
39
|
+
.values({
|
|
40
|
+
id,
|
|
41
|
+
name: id,
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
state,
|
|
44
|
+
manifestData: { id, name: id, version: '1.0.0' },
|
|
45
|
+
sourcePath: `/tmp/${id}`,
|
|
46
|
+
pausedAt: state === 'PAUSED' ? (opts.pausedAt ?? new Date()) : null,
|
|
47
|
+
pauseReason: state === 'PAUSED' ? (opts.reason ?? null) : null,
|
|
48
|
+
})
|
|
49
|
+
.run();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('system doctor reports ANY paused module (task 6.2 / 6.4)', () => {
|
|
53
|
+
test('with nothing paused the check passes', () => {
|
|
54
|
+
const db = makeDb();
|
|
55
|
+
insert(db, 'caddy', 'VERIFIED');
|
|
56
|
+
|
|
57
|
+
const finding = checkPausedModules(db);
|
|
58
|
+
expect(finding.status).toBe('ok');
|
|
59
|
+
expect(finding.summary).toBe('nothing paused');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('PROVE IT FAILS: one paused module is a doctor FAILURE naming it and its age', () => {
|
|
63
|
+
const db = makeDb();
|
|
64
|
+
insert(db, 'caddy', 'VERIFIED');
|
|
65
|
+
insert(db, 'greenwave', 'PAUSED', {
|
|
66
|
+
pausedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
|
|
67
|
+
reason: 'edge router swap',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const finding = checkPausedModules(db);
|
|
71
|
+
|
|
72
|
+
expect(finding.status).toBe('fail');
|
|
73
|
+
expect(finding.summary).toContain('greenwave');
|
|
74
|
+
// The DURATION is what separates a maintenance window from a forgotten one.
|
|
75
|
+
expect(finding.detail.join('\n')).toContain('3d');
|
|
76
|
+
expect(finding.detail.join('\n')).toContain('edge router swap');
|
|
77
|
+
expect(finding.remediation).toContain('celilo module unpause');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('there is no threshold — a pause taken seconds ago already fails', () => {
|
|
81
|
+
// Decided at review: a pause is a degraded state, full stop. A threshold is
|
|
82
|
+
// just something to tune until the detector stops firing.
|
|
83
|
+
const db = makeDb();
|
|
84
|
+
insert(db, 'greenwave', 'PAUSED', { pausedAt: new Date() });
|
|
85
|
+
expect(checkPausedModules(db).status).toBe('fail');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('listPausedModules / pausedAmong', () => {
|
|
90
|
+
test('reads only PAUSED rows, and reads back the reason', () => {
|
|
91
|
+
const db = makeDb();
|
|
92
|
+
insert(db, 'caddy', 'INSTALLED');
|
|
93
|
+
insert(db, 'greenwave', 'PAUSED', { reason: 'edge router swap' });
|
|
94
|
+
insert(db, 'authentik', 'ERROR');
|
|
95
|
+
|
|
96
|
+
const paused = listPausedModules(db);
|
|
97
|
+
expect(paused.map((m) => m.id)).toEqual(['greenwave']);
|
|
98
|
+
expect(paused[0].pauseReason).toBe('edge router swap');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an ERROR module is NOT paused — the two states are distinct', () => {
|
|
102
|
+
// Worth pinning: ERROR is *pausable*, which is easy to misread as "errored
|
|
103
|
+
// counts as paused" and would make the doctor check fire on every failure.
|
|
104
|
+
const db = makeDb();
|
|
105
|
+
insert(db, 'authentik', 'ERROR');
|
|
106
|
+
expect(listPausedModules(db)).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('pausedAmong filters a candidate set in one query', () => {
|
|
110
|
+
const db = makeDb();
|
|
111
|
+
insert(db, 'caddy', 'PAUSED');
|
|
112
|
+
insert(db, 'authentik', 'INSTALLED');
|
|
113
|
+
expect(pausedAmong(db, ['caddy', 'authentik', 'absent'])).toEqual(new Set(['caddy']));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('pausedAmong on an empty list does not query at all', () => {
|
|
117
|
+
const db = makeDb();
|
|
118
|
+
expect(pausedAmong(db, [])).toEqual(new Set());
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('the management-API warning is emitted, and NOT emitted (task 6.5 / 6.6)', () => {
|
|
123
|
+
// `fleetWarnings()` in api/serve.ts is the production caller; this pins the
|
|
124
|
+
// query + rendering it depends on. The inverse assertion is the one that
|
|
125
|
+
// stops the warning becoming noise nobody reads.
|
|
126
|
+
test('with nothing paused there is nothing to warn about', () => {
|
|
127
|
+
const db = makeDb();
|
|
128
|
+
insert(db, 'caddy', 'VERIFIED');
|
|
129
|
+
expect(listPausedModules(db)).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('with something paused the warning names the module AND its age', () => {
|
|
133
|
+
const db = makeDb();
|
|
134
|
+
insert(db, 'greenwave', 'PAUSED', {
|
|
135
|
+
pausedAt: new Date(Date.now() - 5 * 60 * 60 * 1000),
|
|
136
|
+
reason: 'edge router swap',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const rendered = listPausedModules(db).map((m) => describePausedModule(m));
|
|
140
|
+
expect(rendered).toEqual(['greenwave (5h, "edge router swap")']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a pause with no reason still renders its age', () => {
|
|
144
|
+
const db = makeDb();
|
|
145
|
+
insert(db, 'greenwave', 'PAUSED', { pausedAt: new Date(Date.now() - 60 * 60 * 1000) });
|
|
146
|
+
expect(describePausedModule(listPausedModules(db)[0])).toBe('greenwave (1h)');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('a paused module is suppressed BY THE PAUSE, not anonymously (task 2.3)', () => {
|
|
151
|
+
const topology = { moduleSystems: [], zoneProviders: [] };
|
|
152
|
+
|
|
153
|
+
test('PROVE IT FIRES: an unpaused module with nothing firing is NOT suppressed', () => {
|
|
154
|
+
expect(
|
|
155
|
+
findSuppressor({
|
|
156
|
+
key: moduleAlertKey('caddy'),
|
|
157
|
+
firingKeys: new Set(),
|
|
158
|
+
suppressible: true,
|
|
159
|
+
modulesInDeployWindow: new Set(),
|
|
160
|
+
pausedModules: new Set(),
|
|
161
|
+
topology,
|
|
162
|
+
}),
|
|
163
|
+
).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the same alert IS suppressed once the module is paused, attributed to the pause', () => {
|
|
167
|
+
expect(
|
|
168
|
+
findSuppressor({
|
|
169
|
+
key: moduleAlertKey('caddy'),
|
|
170
|
+
firingKeys: new Set(),
|
|
171
|
+
suppressible: true,
|
|
172
|
+
modulesInDeployWindow: new Set(),
|
|
173
|
+
pausedModules: new Set(['caddy']),
|
|
174
|
+
topology,
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({ kind: 'paused', moduleId: 'caddy' });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a pause does not suppress OTHER modules', () => {
|
|
180
|
+
expect(
|
|
181
|
+
findSuppressor({
|
|
182
|
+
key: moduleAlertKey('authentik'),
|
|
183
|
+
firingKeys: new Set(),
|
|
184
|
+
suppressible: true,
|
|
185
|
+
modulesInDeployWindow: new Set(),
|
|
186
|
+
pausedModules: new Set(['caddy']),
|
|
187
|
+
topology,
|
|
188
|
+
}),
|
|
189
|
+
).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a self-monitor is never suppressed, even by a pause', () => {
|
|
193
|
+
// A cascading failure must not silence the component reporting the cascade.
|
|
194
|
+
expect(
|
|
195
|
+
findSuppressor({
|
|
196
|
+
key: moduleAlertKey('caddy'),
|
|
197
|
+
firingKeys: new Set(),
|
|
198
|
+
suppressible: false,
|
|
199
|
+
modulesInDeployWindow: new Set(),
|
|
200
|
+
pausedModules: new Set(['caddy']),
|
|
201
|
+
topology,
|
|
202
|
+
}),
|
|
203
|
+
).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('--stop-infra acts only on celilo-provisioned infrastructure (design D2, revised)', () => {
|
|
208
|
+
// The RULING is about ownership, not capability, and the distinction lives in
|
|
209
|
+
// the message the operator reads — so the message is what gets pinned.
|
|
210
|
+
//
|
|
211
|
+
// celilo could SSH into a machine and stop something. It declines to, because
|
|
212
|
+
// a machine-pool system is operator-pre-provisioned, may predate celilo, and
|
|
213
|
+
// may run work celilo was never told about. Reporting "celilo cannot identify
|
|
214
|
+
// the service unit" would imply a capability gap where the truth is that this
|
|
215
|
+
// is not celilo's to stop.
|
|
216
|
+
test('the machine-hosted report reads as not-applicable, not as a missing feature', () => {
|
|
217
|
+
const report = describeMachineStopInfra('iot', 'homebridge');
|
|
218
|
+
|
|
219
|
+
expect(report).toContain('not applicable');
|
|
220
|
+
expect(report).toContain('celilo provisioned');
|
|
221
|
+
// Must NOT frame it as something celilo would do if only it could.
|
|
222
|
+
expect(report).not.toMatch(/cannot determine|does not know which|unit/i);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-plane quiescence (tasks 2.1, 2.2, 2.5).
|
|
3
|
+
*
|
|
4
|
+
* Every assertion is made in BOTH directions per Rule 7.6. "The hook did not
|
|
5
|
+
* fire" is the same observation you get from a test that was never wired up, so
|
|
6
|
+
* each case first proves the UNPAUSED module gets through the guard.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdtempSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { eq } from 'drizzle-orm';
|
|
14
|
+
import { handleEventsRunHook } from '../cli/commands/events';
|
|
15
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
16
|
+
import { type ModuleState, modules } from '../db/schema';
|
|
17
|
+
import { runNamedHook } from '../hooks/run-named-hook';
|
|
18
|
+
import type { HookLogger } from '../hooks/types';
|
|
19
|
+
|
|
20
|
+
const SILENT_LOGGER: HookLogger = {
|
|
21
|
+
info: () => {},
|
|
22
|
+
warn: () => {},
|
|
23
|
+
error: () => {},
|
|
24
|
+
debug: () => {},
|
|
25
|
+
} as unknown as HookLogger;
|
|
26
|
+
|
|
27
|
+
let db: DbClient;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A manifest declaring the timer- and event-driven hooks that are the runtime
|
|
31
|
+
* hazard — the ones the dispatcher fires against celilo-mgr's module store and
|
|
32
|
+
* that can call into a provider that is gone.
|
|
33
|
+
*/
|
|
34
|
+
function manifestWithHooks(id: string) {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
name: id,
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
celilo_contract: '1.0',
|
|
40
|
+
provides: { capabilities: [] },
|
|
41
|
+
requires: { capabilities: [] },
|
|
42
|
+
hooks: {
|
|
43
|
+
on_install: { script: 'scripts/on-install.ts' },
|
|
44
|
+
on_uninstall: { script: 'scripts/on-uninstall.ts' },
|
|
45
|
+
on_system_event: { script: 'scripts/on-system-event.ts' },
|
|
46
|
+
refresh_registrations: { script: 'scripts/refresh-registrations.ts' },
|
|
47
|
+
},
|
|
48
|
+
subscriptions: [{ name: 'refresh', pattern: 'timer.tick.15m', hook: 'refresh_registrations' }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertModule(id: string, state: ModuleState): void {
|
|
53
|
+
db.insert(modules)
|
|
54
|
+
.values({
|
|
55
|
+
id,
|
|
56
|
+
name: id,
|
|
57
|
+
version: '1.0.0',
|
|
58
|
+
state,
|
|
59
|
+
manifestData: manifestWithHooks(id),
|
|
60
|
+
sourcePath: join(tmpdir(), 'celilo-nonexistent-module'),
|
|
61
|
+
pausedAt: state === 'PAUSED' ? new Date() : null,
|
|
62
|
+
pauseReason: state === 'PAUSED' ? 'edge router swap' : null,
|
|
63
|
+
})
|
|
64
|
+
.run();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-quiesce-'));
|
|
69
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
70
|
+
process.env.CELILO_EVENT_BUS_PATH = join(dir, 'events.db');
|
|
71
|
+
db = createDbClient({ path: process.env.CELILO_DB_PATH });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
db.$client.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('runNamedHook refuses to run dispatched work on a paused module', () => {
|
|
79
|
+
test('PROVE IT RUNS: an unpaused module is NOT short-circuited by the pause guard', async () => {
|
|
80
|
+
insertModule('caddy', 'INSTALLED');
|
|
81
|
+
const result = await runNamedHook('caddy', 'on_system_event', db, SILENT_LOGGER);
|
|
82
|
+
|
|
83
|
+
// The hook script does not exist, so this fails downstream — the point is
|
|
84
|
+
// only that it got PAST the guard, which `skippedPaused` proves it did.
|
|
85
|
+
expect(result.skippedPaused).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a paused module is skipped instead', async () => {
|
|
89
|
+
insertModule('caddy', 'PAUSED');
|
|
90
|
+
const result = await runNamedHook('caddy', 'on_system_event', db, SILENT_LOGGER);
|
|
91
|
+
|
|
92
|
+
expect(result.skippedPaused).toBe(true);
|
|
93
|
+
// Success, not failure: the module is deliberately quiesced. A failure here
|
|
94
|
+
// would be retried by the bus and then alerted on, paging the operator
|
|
95
|
+
// about the pause they took themselves.
|
|
96
|
+
expect(result.success).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a paused module is skipped for timer-driven hooks too', async () => {
|
|
100
|
+
insertModule('technitium', 'PAUSED');
|
|
101
|
+
const result = await runNamedHook('technitium', 'refresh_registrations', db, SILENT_LOGGER);
|
|
102
|
+
expect(result.skippedPaused).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test.each([['on_install'], ['on_uninstall']] as const)(
|
|
106
|
+
'%s is EXEMPT — it must run while the module is still paused',
|
|
107
|
+
async (hookName) => {
|
|
108
|
+
// on_install is how unpause redeploys the module back to life, and
|
|
109
|
+
// on_uninstall is how a paused provider is removed — which is the entire
|
|
110
|
+
// point of pausing its consumers. Blocking either would deadlock the
|
|
111
|
+
// whole design.
|
|
112
|
+
insertModule('greenwave', 'PAUSED');
|
|
113
|
+
const result = await runNamedHook(
|
|
114
|
+
'greenwave',
|
|
115
|
+
hookName as 'on_install' | 'on_uninstall',
|
|
116
|
+
db,
|
|
117
|
+
SILENT_LOGGER,
|
|
118
|
+
);
|
|
119
|
+
expect(result.skippedPaused).toBeUndefined();
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('the bus dispatch entry point skips a paused module (task 2.1)', () => {
|
|
125
|
+
// `celilo events run-hook <module> <sub>` is what every `hook:` subscription
|
|
126
|
+
// resolves to, so this is where an event delivery lands.
|
|
127
|
+
test('PROVE IT PROCEEDS: an unpaused module gets past the guard to subscription lookup', async () => {
|
|
128
|
+
insertModule('caddy', 'INSTALLED');
|
|
129
|
+
const result = await handleEventsRunHook(['caddy', 'no-such-subscription', '1']);
|
|
130
|
+
|
|
131
|
+
// Reaching the "no such subscription" error proves the pause guard let it
|
|
132
|
+
// through; a paused module never gets this far.
|
|
133
|
+
expect(result.success).toBe(false);
|
|
134
|
+
expect(result.success === false ? result.error : '').toContain('no subscription named');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a paused module is skipped before any subscription is resolved', async () => {
|
|
138
|
+
insertModule('caddy', 'PAUSED');
|
|
139
|
+
const result = await handleEventsRunHook(['caddy', 'no-such-subscription', '1']);
|
|
140
|
+
|
|
141
|
+
expect(result.success).toBe(true);
|
|
142
|
+
expect(result.success === true ? result.message : '').toContain('paused');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('resyncAllSubscriptions cannot re-arm a paused module', () => {
|
|
147
|
+
test('a paused module is outside the deployed set the resync rebuilds from', () => {
|
|
148
|
+
// The resync selects INSTALLED/VERIFIED. That allow-list is what makes
|
|
149
|
+
// quiescence survive a restore (which starts events.db empty) — worth
|
|
150
|
+
// pinning, because widening it to "not IMPORTED" would silently un-pause
|
|
151
|
+
// every paused module on the next resync.
|
|
152
|
+
insertModule('caddy', 'PAUSED');
|
|
153
|
+
insertModule('authentik', 'INSTALLED');
|
|
154
|
+
|
|
155
|
+
const deployed = db
|
|
156
|
+
.select({ id: modules.id })
|
|
157
|
+
.from(modules)
|
|
158
|
+
.where(eq(modules.state, 'PAUSED'))
|
|
159
|
+
.all();
|
|
160
|
+
|
|
161
|
+
expect(deployed.map((m) => m.id)).toEqual(['caddy']);
|
|
162
|
+
});
|
|
163
|
+
});
|