@celilo/cli 0.22.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.
Files changed (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +34 -2
  2. package/drizzle/0024_module_pause.sql +20 -0
  3. package/drizzle/meta/_journal.json +8 -1
  4. package/package.json +4 -5
  5. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  6. package/src/api/remote-client.test.ts +6 -5
  7. package/src/api/serve.ts +41 -7
  8. package/src/api-clients/proxmox.ts +34 -0
  9. package/src/cli/commands/alerts-sweep.ts +2 -0
  10. package/src/cli/commands/events.ts +34 -3
  11. package/src/cli/commands/module-deploy.ts +2 -2
  12. package/src/cli/commands/module-health.ts +1 -0
  13. package/src/cli/commands/module-import.ts +3 -3
  14. package/src/cli/commands/module-list.ts +12 -1
  15. package/src/cli/commands/module-pause.ts +317 -0
  16. package/src/cli/commands/module-remove.ts +78 -40
  17. package/src/cli/commands/module-status.ts +3 -4
  18. package/src/cli/commands/module-update.test.ts +1 -1
  19. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  20. package/src/cli/commands/status.ts +25 -3
  21. package/src/cli/completion.ts +4 -0
  22. package/src/cli/fuel-gauge.ts +4 -4
  23. package/src/cli/index.ts +45 -20
  24. package/src/cli/json-output.test.ts +162 -0
  25. package/src/cli/prompts.ts +53 -74
  26. package/src/cli/service-credential.ts +3 -3
  27. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  28. package/src/cli/types.ts +7 -2
  29. package/src/db/schema.ts +73 -15
  30. package/src/hooks/run-named-hook.ts +28 -0
  31. package/src/services/alerting/suppression.test.ts +5 -0
  32. package/src/services/alerting/suppression.ts +18 -1
  33. package/src/services/alerting/sweep-runner.test.ts +1 -0
  34. package/src/services/alerting/sweep-runner.ts +11 -1
  35. package/src/services/bus-interview.ts +2 -2
  36. package/src/services/bus-secret-flow.test.ts +1 -1
  37. package/src/services/fleet-checks.ts +48 -0
  38. package/src/services/module-deploy.ts +1 -1
  39. package/src/services/module-pause-observability.test.ts +224 -0
  40. package/src/services/module-pause-quiescence.test.ts +163 -0
  41. package/src/services/module-pause.test.ts +573 -0
  42. package/src/services/module-pause.ts +544 -0
  43. package/src/services/remove-guard.test.ts +175 -0
  44. package/src/services/remove-guard.ts +109 -0
  45. package/src/services/terminal-responder.ts +16 -16
  46. package/src/services/update/dep-graph.test.ts +33 -4
  47. package/src/services/update/dep-graph.ts +39 -17
  48. package/src/services/zone-detector.ts +2 -39
  49. package/src/test-utils/cli.ts +15 -14
  50. package/src/test-utils/integration-guard.ts +26 -0
  51. package/src/test-utils/setup-test-db.ts +13 -23
@@ -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
+ });