@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
|
@@ -151,6 +151,39 @@ export function parseStoredConfigValue(
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* A module's whole operator config as a plain key → typed-value map — the shape
|
|
156
|
+
* every policy resolver takes.
|
|
157
|
+
*/
|
|
158
|
+
export function loadModuleConfigs(db: DbClient, moduleId: string): Record<string, unknown> {
|
|
159
|
+
const configs: Record<string, unknown> = {};
|
|
160
|
+
for (const row of db
|
|
161
|
+
.select()
|
|
162
|
+
.from(moduleConfigs)
|
|
163
|
+
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
164
|
+
.all()) {
|
|
165
|
+
configs[row.key] = parseStoredConfigValue(row);
|
|
166
|
+
}
|
|
167
|
+
return configs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The raw string form of an operator override from an already-loaded config
|
|
172
|
+
* map, or undefined when the key is unset.
|
|
173
|
+
*
|
|
174
|
+
* Framework policy keys (cadences, retention, upgrade controls) are stored
|
|
175
|
+
* through the same typed path as any other config, so a cadence arrives as a
|
|
176
|
+
* string and a retention count as a number. Every resolver takes the string
|
|
177
|
+
* form and parses it itself, so this is the one place that flattening happens.
|
|
178
|
+
*/
|
|
179
|
+
export function configOverride(
|
|
180
|
+
configs: Record<string, unknown> | undefined,
|
|
181
|
+
key: string,
|
|
182
|
+
): string | undefined {
|
|
183
|
+
const raw = configs?.[key];
|
|
184
|
+
return raw === undefined || raw === null ? undefined : String(raw);
|
|
185
|
+
}
|
|
186
|
+
|
|
154
187
|
/**
|
|
155
188
|
* Get module configuration value
|
|
156
189
|
* Returns parsed value (primitive or complex type)
|
|
@@ -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
|
+
});
|