@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,175 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModuleManifest } from '../manifest/schema';
3
+ import {
4
+ type DependentCandidate,
5
+ describeRemovalRefusal,
6
+ findRemovalBlockers,
7
+ } from './remove-guard';
8
+
9
+ function manifest(
10
+ id: string,
11
+ opts: { requires?: string[]; optional?: string[] } = {},
12
+ ): ModuleManifest {
13
+ return {
14
+ id,
15
+ name: id,
16
+ version: '1.0.0',
17
+ celilo_contract: '1.0',
18
+ provides: { capabilities: [] },
19
+ requires: { capabilities: (opts.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
20
+ optional: opts.optional
21
+ ? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) }
22
+ : undefined,
23
+ } as unknown as ModuleManifest;
24
+ }
25
+
26
+ function candidate(
27
+ id: string,
28
+ opts: { requires?: string[]; optional?: string[]; paused?: boolean; deployed?: boolean } = {},
29
+ ): DependentCandidate {
30
+ return {
31
+ id,
32
+ manifest: manifest(id, opts),
33
+ paused: opts.paused ?? false,
34
+ deployed: opts.deployed ?? true,
35
+ };
36
+ }
37
+
38
+ /** What `greenwave` provides, and the consumers that wedged its removal. */
39
+ const GREENWAVE_CAPABILITIES = ['firewall', 'dhcp_server'];
40
+
41
+ describe('findRemovalBlockers — the guard that wedged the greenwave swap', () => {
42
+ test('a live consumer blocks removal', () => {
43
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
44
+ candidate('caddy', { requires: ['firewall'] }),
45
+ ]);
46
+ expect(blockers).toEqual([{ moduleId: 'caddy', capability: 'firewall', kind: 'requires' }]);
47
+ });
48
+
49
+ test('a PAUSED consumer does not block (task 5.1)', () => {
50
+ // Sound because unpause cannot return it to service without a redeploy, and
51
+ // a redeploy re-resolves its capabilities against whatever is present then.
52
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
53
+ candidate('caddy', { requires: ['firewall'], paused: true }),
54
+ ]);
55
+ expect(blockers).toEqual([]);
56
+ });
57
+
58
+ test('a PARTIALLY paused dependent set still blocks, naming only the live ones', () => {
59
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
60
+ candidate('caddy', { requires: ['firewall'], paused: true }),
61
+ candidate('authentik', { requires: ['firewall'] }),
62
+ ]);
63
+ expect(blockers.map((b) => b.moduleId)).toEqual(['authentik']);
64
+ });
65
+ });
66
+
67
+ describe('findRemovalBlockers — the optional-dependency regression (task 5.4)', () => {
68
+ // technitium consumes dhcp_server under `optional:`, so the old guard —
69
+ // which read `requires` alone — let a greenwave removal orphan its
70
+ // DHCP-handed-out DNS silently while correctly blocking on caddy.
71
+ test('an optional consumer NOW blocks, where it used to be invisible', () => {
72
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
73
+ candidate('technitium', { optional: ['dhcp_server'] }),
74
+ ]);
75
+ expect(blockers).toEqual([
76
+ { moduleId: 'technitium', capability: 'dhcp_server', kind: 'optional' },
77
+ ]);
78
+ });
79
+
80
+ test('and stops blocking once paused', () => {
81
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
82
+ candidate('technitium', { optional: ['dhcp_server'], paused: true }),
83
+ ]);
84
+ expect(blockers).toEqual([]);
85
+ });
86
+
87
+ test('a module depending BOTH ways is reported once, as the stronger claim', () => {
88
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
89
+ candidate('both', { requires: ['firewall'], optional: ['dhcp_server'] }),
90
+ ]);
91
+ expect(blockers).toHaveLength(1);
92
+ expect(blockers[0].kind).toBe('requires');
93
+ });
94
+ });
95
+
96
+ describe('findRemovalBlockers — deliberate non-goals', () => {
97
+ test('another provider existing does NOT by itself permit removal (celilo#683, task 5.5)', () => {
98
+ // `axon` provides the same capabilities as `greenwave`. The guard still
99
+ // refuses, because celilo does not reason about provider substitutability —
100
+ // pausing is the mechanism this change relies on instead.
101
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
102
+ candidate('axon'),
103
+ candidate('caddy', { requires: ['firewall'] }),
104
+ ]);
105
+ expect(blockers.map((b) => b.moduleId)).toEqual(['caddy']);
106
+ });
107
+
108
+ test('a module consuming an unrelated capability is not a dependent', () => {
109
+ expect(
110
+ findRemovalBlockers(GREENWAVE_CAPABILITIES, [
111
+ candidate('blog', { requires: ['public_web'] }),
112
+ ]),
113
+ ).toEqual([]);
114
+ });
115
+
116
+ test('a provider of nothing blocks nobody', () => {
117
+ expect(findRemovalBlockers([], [candidate('caddy', { requires: ['firewall'] })])).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe('describeRemovalRefusal', () => {
122
+ test('names each blocker AND which declaration makes it one (task 5.3)', () => {
123
+ const message = describeRemovalRefusal('greenwave', [
124
+ { moduleId: 'caddy', capability: 'firewall', kind: 'requires' },
125
+ { moduleId: 'technitium', capability: 'dhcp_server', kind: 'optional' },
126
+ ]);
127
+
128
+ // The operator needs to tell a hard prerequisite from an optional consumer
129
+ // to judge what pausing it implies.
130
+ expect(message).toContain("caddy — requires 'firewall'");
131
+ expect(message).toContain("technitium — optional 'dhcp_server'");
132
+ // And it must point at the way out, since that is the whole change.
133
+ expect(message).toContain('celilo module pause --cascade greenwave');
134
+ });
135
+ });
136
+
137
+ describe('an undeployed dependent does not block (guard/cascade agreement)', () => {
138
+ // The spec says removal is refused for another INSTALLED module. An imported
139
+ // module has never resolved the capability and cannot be orphaned by the
140
+ // provider going away.
141
+ //
142
+ // This is load-bearing for D3's guard/cascade agreement, not a nicety.
143
+ // `pause --cascade` SKIPS undeployed members — pausing needs a settled
144
+ // deployed state and there is nothing to quiesce. If the guard still counted
145
+ // them, the operator would pause the whole cascade and the removal would
146
+ // still refuse, naming a module that pause is structurally unable to act on.
147
+ // That is a dead end: no sequence of commands gets the operator out of it.
148
+ test('an IMPORTED consumer is not a blocker', () => {
149
+ expect(
150
+ findRemovalBlockers(GREENWAVE_CAPABILITIES, [
151
+ candidate('technitium', { optional: ['dhcp_server'], deployed: false }),
152
+ ]),
153
+ ).toEqual([]);
154
+ });
155
+
156
+ test('but a DEPLOYED one still is', () => {
157
+ expect(
158
+ findRemovalBlockers(GREENWAVE_CAPABILITIES, [
159
+ candidate('technitium', { optional: ['dhcp_server'], deployed: true }),
160
+ ]).map((b) => b.moduleId),
161
+ ).toEqual(['technitium']);
162
+ });
163
+
164
+ test('the guard and the cascade agree on every combination', () => {
165
+ // Whatever pause can act on, the guard must count; whatever pause skips,
166
+ // the guard must ignore. Enumerated so a future change to either side
167
+ // cannot silently break the pairing.
168
+ const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
169
+ candidate('live', { requires: ['firewall'] }),
170
+ candidate('paused', { requires: ['firewall'], paused: true }),
171
+ candidate('imported', { requires: ['firewall'], deployed: false }),
172
+ ]);
173
+ expect(blockers.map((b) => b.moduleId)).toEqual(['live']);
174
+ });
175
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The dependency guard on `module remove`.
3
+ *
4
+ * Extracted from `cli/commands/module-remove.ts` so it can be unit-tested
5
+ * without a database, and so the two behaviour changes it carries are visible
6
+ * in one place (openspec/changes/module-pause-lifecycle, design D3):
7
+ *
8
+ * 1. A PAUSED module is not a dependent. Sound rather than a loosening: a
9
+ * paused module cannot return to service without a redeploy, and that
10
+ * redeploy re-resolves its capabilities against whatever providers exist
11
+ * at the time. The invariant is "paused implies not currently bound, and
12
+ * guaranteed to rebind before going live".
13
+ *
14
+ * 2. A dependent is one that declares the capability under `requires` OR
15
+ * `optional`, which is what `services/update/dep-graph.ts` already means
16
+ * by an edge. The guard used to consider `requires` alone, and the two
17
+ * definitions disagreeing was silently harmful: `technitium` consumes
18
+ * `dhcp_server` under `optional:`, so removing its provider orphaned its
19
+ * DHCP-handed-out DNS without a word while correctly blocking on `caddy`.
20
+ * The guard and the cascade MUST agree on the set, or `pause --cascade`
21
+ * pauses a set the guard still rejects.
22
+ *
23
+ * Note (2) makes the guard STRICTER for optional consumers while (1) makes it
24
+ * looser for paused ones. Both directions are intended.
25
+ */
26
+
27
+ import type { ModuleManifest } from '../manifest/schema';
28
+
29
+ /** How a module came to depend on the capability — reported, not just counted. */
30
+ export type DependencyKind = 'requires' | 'optional';
31
+
32
+ export interface Blocker {
33
+ moduleId: string;
34
+ capability: string;
35
+ kind: DependencyKind;
36
+ }
37
+
38
+ export interface DependentCandidate {
39
+ id: string;
40
+ manifest: ModuleManifest;
41
+ paused: boolean;
42
+ /**
43
+ * Has this module actually been deployed? The spec says removal is refused
44
+ * when another **installed** module depends on the capability, and an
45
+ * imported-but-never-deployed module is not installed: it has never resolved
46
+ * the capability, holds no state derived from it, and cannot be orphaned by
47
+ * the provider going away.
48
+ *
49
+ * This is not a nicety — it is required for the guard and the cascade to
50
+ * agree (design D3). `pause --cascade` skips undeployed members, because
51
+ * pausing requires a settled deployed state and there is nothing to quiesce.
52
+ * If the guard still counted them, the operator would pause the full cascade
53
+ * and the removal would STILL refuse, naming a module that pause cannot act
54
+ * on — a dead end with no way forward.
55
+ */
56
+ deployed: boolean;
57
+ }
58
+
59
+ /**
60
+ * Modules that block removal of a provider of `providedCapabilities`.
61
+ *
62
+ * Pure. Paused candidates are excluded; a candidate depending via both
63
+ * `requires` and `optional` is reported once, as `requires` (the stronger
64
+ * claim, and the one whose removal consequence is worse).
65
+ */
66
+ export function findRemovalBlockers(
67
+ providedCapabilities: Iterable<string>,
68
+ candidates: DependentCandidate[],
69
+ ): Blocker[] {
70
+ const provided = new Set(providedCapabilities);
71
+ if (provided.size === 0) return [];
72
+
73
+ const blockers: Blocker[] = [];
74
+ for (const candidate of candidates) {
75
+ if (candidate.paused) continue;
76
+ if (!candidate.deployed) continue;
77
+
78
+ const required = (candidate.manifest.requires?.capabilities ?? []).map((c) => c.name);
79
+ const optional = (candidate.manifest.optional?.capabilities ?? []).map((c) => c.name);
80
+
81
+ const hit =
82
+ required.find((name) => provided.has(name)) ?? optional.find((name) => provided.has(name));
83
+ if (!hit) continue;
84
+
85
+ blockers.push({
86
+ moduleId: candidate.id,
87
+ capability: hit,
88
+ kind: required.includes(hit) ? 'requires' : 'optional',
89
+ });
90
+ }
91
+
92
+ return blockers.sort((a, b) => a.moduleId.localeCompare(b.moduleId));
93
+ }
94
+
95
+ /**
96
+ * The refusal an operator reads. Names each blocker AND which declaration makes
97
+ * it one, so a hard prerequisite is distinguishable from an optional consumer —
98
+ * the operator needs that to judge what pausing it implies.
99
+ */
100
+ export function describeRemovalRefusal(moduleId: string, blockers: Blocker[]): string {
101
+ const lines = blockers.map((b) => ` • ${b.moduleId} — ${b.kind} '${b.capability}'`);
102
+ return [
103
+ `Cannot remove '${moduleId}': the following installed modules depend on its capabilities:`,
104
+ ...lines,
105
+ '',
106
+ 'Pause them first so they rebind on their next deploy:',
107
+ ` celilo module pause --cascade ${moduleId}`,
108
+ ].join('\n');
109
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Terminal-responder: when a deploy runs on a TTY, this subscribes
3
3
  * to `config.required.*`, `secret.required.*`, and `ensure.required.*`
4
- * events and prompts the operator via clack for each one, replying
5
- * on the bus.
4
+ * events and prompts the operator on the terminal for each one,
5
+ * replying on the bus.
6
6
  *
7
7
  * Just one of several responder shapes — the bus query/reply path is
8
8
  * a race, and the terminal is one racer. Other racers (Claude
@@ -26,9 +26,9 @@
26
26
  */
27
27
 
28
28
  import { hostname } from 'node:os';
29
+ import { confirm, isCancel, multiselect, select } from '@celilo/cli-display';
29
30
  import { type Bus, openBus } from '@celilo/event-bus';
30
31
  import { defineEvents } from '@celilo/event-bus';
31
- import * as p from '@clack/prompts';
32
32
  import { log, promptPassword, promptText } from '../cli/prompts';
33
33
  import { getEventBusPath } from '../config/paths';
34
34
  import { getDb } from '../db/client';
@@ -61,7 +61,7 @@ export interface TerminalResponderHandle {
61
61
 
62
62
  /**
63
63
  * Register transient subscriptions on the three interview event
64
- * families. For each event, prompt the operator via clack, optionally
64
+ * families. For each event, prompt the operator on the terminal, optionally
65
65
  * inject the secret value out-of-band, and reply on the bus.
66
66
  *
67
67
  * Returns a handle the caller can close() when the deploy completes.
@@ -103,9 +103,9 @@ export function startTerminalResponder(): TerminalResponderHandle {
103
103
 
104
104
  if (payload.options && payload.options.length > 0) {
105
105
  // Multi-select prompt for vars with options[] declared in the
106
- // manifest. clack's multiselect returns an array of selected
107
- // values directly — no JSON-typing to coerce.
108
- const selected = await p.multiselect({
106
+ // manifest. multiselect returns an array of selected values
107
+ // directly — no JSON-typing to coerce.
108
+ const selected = await multiselect({
109
109
  message,
110
110
  options: payload.options.map((opt) => ({
111
111
  value: opt.value,
@@ -114,7 +114,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
114
114
  })),
115
115
  required: payload.required,
116
116
  });
117
- if (p.isCancel(selected)) {
117
+ if (isCancel(selected)) {
118
118
  log.warn(
119
119
  `Terminal responder: cancelled prompt for ${payload.module}.${payload.key}; no reply emitted`,
120
120
  );
@@ -308,11 +308,11 @@ export function startTerminalResponder(): TerminalResponderHandle {
308
308
  let value: unknown;
309
309
 
310
310
  if (payload.kind === 'confirm') {
311
- const answer = await p.confirm({
311
+ const answer = await confirm({
312
312
  message,
313
313
  initialValue: payload.defaultValue === 'true',
314
314
  });
315
- if (p.isCancel(answer)) {
315
+ if (isCancel(answer)) {
316
316
  log.warn(
317
317
  `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
318
318
  );
@@ -320,7 +320,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
320
320
  }
321
321
  value = answer;
322
322
  } else if (payload.kind === 'select') {
323
- const answer = await p.select({
323
+ const answer = await select({
324
324
  message,
325
325
  options: (payload.options ?? []).map((opt) => ({
326
326
  value: opt.value,
@@ -329,7 +329,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
329
329
  })),
330
330
  initialValue: payload.defaultValue,
331
331
  });
332
- if (p.isCancel(answer)) {
332
+ if (isCancel(answer)) {
333
333
  log.warn(
334
334
  `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
335
335
  );
@@ -337,7 +337,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
337
337
  }
338
338
  value = answer;
339
339
  } else if (payload.kind === 'multiselect') {
340
- const answer = await p.multiselect({
340
+ const answer = await multiselect({
341
341
  message,
342
342
  options: (payload.options ?? []).map((opt) => ({
343
343
  value: opt.value,
@@ -346,7 +346,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
346
346
  })),
347
347
  required: payload.required,
348
348
  });
349
- if (p.isCancel(answer)) {
349
+ if (isCancel(answer)) {
350
350
  log.warn(
351
351
  `Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
352
352
  );
@@ -624,8 +624,8 @@ function describeTypeHint(type: ConfigRequiredPayload['type']): string | null {
624
624
  }
625
625
 
626
626
  function coerceValue(raw: string | undefined, type: ConfigRequiredPayload['type']): unknown {
627
- // clack returns undefined when the user cancels (Ctrl+C). Bubble
628
- // that up as an error so validate sees it and the responder can
627
+ // The prompt layer yields undefined when the user cancels (Ctrl+C).
628
+ // Bubble that up as an error so validate sees it and the responder can
629
629
  // skip the reply rather than emit a malformed one.
630
630
  if (raw === undefined) {
631
631
  throw new Error('Cancelled');
@@ -80,13 +80,42 @@ describe('buildModuleGraph', () => {
80
80
  expect(g.edges.get('weird')?.size).toBe(0);
81
81
  });
82
82
 
83
- test('first declared provider wins when multiple modules provide the same capability', () => {
83
+ // Previously asserted "first declared provider wins". That was the behaviour,
84
+ // and it was a defect: `capability-loader.ts` resolves a `firewall` consumer
85
+ // through `buildFirewallChain()`, which wires a chain across EVERY provider —
86
+ // the edge device that owns egress plus the downstream layers. First-wins made
87
+ // the others invisible, so `module pause --cascade greenwave` omitted `caddy`
88
+ // even though caddy requires `firewall` and greenwave provides it, and the
89
+ // remove guard (which reads all providers) then refused the removal the
90
+ // cascade was supposed to enable.
91
+ test('a consumer depends on EVERY provider of a capability, not just the first', () => {
84
92
  const g = buildModuleGraph([
85
- makeManifest('a', { provides: ['firewall'] }),
86
- makeManifest('b', { provides: ['firewall'] }),
93
+ makeManifest('iptables', { provides: ['firewall'] }),
94
+ makeManifest('greenwave', { provides: ['firewall'] }),
95
+ makeManifest('caddy', { requires: ['firewall'] }),
96
+ ]);
97
+ expect([...(g.edges.get('caddy') ?? [])].sort()).toEqual(['greenwave', 'iptables']);
98
+ });
99
+
100
+ test('and every provider sees that consumer in its reverse index', () => {
101
+ // This is the half `transitiveConsumers` walks, so it is what decides
102
+ // whether a cascade reaches the module at all.
103
+ const g = buildModuleGraph([
104
+ makeManifest('iptables', { provides: ['firewall'] }),
105
+ makeManifest('greenwave', { provides: ['firewall'] }),
87
106
  makeManifest('caddy', { requires: ['firewall'] }),
88
107
  ]);
89
- expect([...(g.edges.get('caddy') ?? [])]).toEqual(['a']);
108
+ expect([...(g.reverseEdges.get('greenwave') ?? [])]).toEqual(['caddy']);
109
+ expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']);
110
+ });
111
+
112
+ test('an optional consumer also edges to every provider', () => {
113
+ const g = buildModuleGraph([
114
+ makeManifest('greenwave', { provides: ['dhcp_server'] }),
115
+ makeManifest('router2', { provides: ['dhcp_server'] }),
116
+ makeManifest('technitium', { optional: ['dhcp_server'] }),
117
+ ]);
118
+ expect([...(g.edges.get('technitium') ?? [])].sort()).toEqual(['greenwave', 'router2']);
90
119
  });
91
120
  });
92
121
 
@@ -45,15 +45,36 @@ export interface ModuleGraph {
45
45
  reverseEdges: Map<ModuleId, Set<ModuleId>>;
46
46
  }
47
47
 
48
- /** Internal: build an index of capability name → providing module id. */
49
- function indexProviders(modules: ModuleNode[]): Map<string, ModuleId> {
50
- const index = new Map<string, ModuleId>();
48
+ /**
49
+ * Internal: index capability name → EVERY module providing it.
50
+ *
51
+ * This used to keep only the first provider, on the reasoning that "one edge per
52
+ * capability is enough for graph-walking". It is not, and the discrepancy is
53
+ * not theoretical: `firewall` is provided by BOTH `iptables` and `greenwave`,
54
+ * and `capability-loader.ts` resolves a consumer's `requires: firewall` through
55
+ * `buildFirewallChain()`, which wires a chain across ALL providers — the edge
56
+ * device that owns egress plus the downstream layers. So a consumer genuinely
57
+ * depends on every one of them.
58
+ *
59
+ * With first-wins, whichever provider happened to be inserted first absorbed the
60
+ * edge and the others became invisible to the graph. `module pause --cascade
61
+ * greenwave` therefore omitted `caddy` entirely, even though caddy requires
62
+ * `firewall` and greenwave provides it — so the operator paused an incomplete
63
+ * set and `module remove` then refused, because the remove guard reads the
64
+ * capabilities table (all providers) and disagreed. That disagreement is exactly
65
+ * what openspec/changes/module-pause-lifecycle design D3 forbids.
66
+ *
67
+ * Keeping all providers makes the graph agree with how capabilities actually
68
+ * resolve. For `topologicalOrder` it means a consumer sorts after every provider
69
+ * of what it consumes, which is strictly more correct for `system update`.
70
+ */
71
+ function indexProviders(modules: ModuleNode[]): Map<string, ModuleId[]> {
72
+ const index = new Map<string, ModuleId[]>();
51
73
  for (const m of modules) {
52
74
  for (const cap of m.provides) {
53
- // First provider wins. The capability-loader's chain logic handles
54
- // multi-provider scenarios at deploy time; for graph-walking
55
- // purposes one edge per capability is enough.
56
- if (!index.has(cap)) index.set(cap, m.id);
75
+ const existing = index.get(cap);
76
+ if (existing) existing.push(m.id);
77
+ else index.set(cap, [m.id]);
57
78
  }
58
79
  }
59
80
  return index;
@@ -82,16 +103,17 @@ export function buildModuleGraph(manifests: ModuleManifest[]): ModuleGraph {
82
103
  reverseEdges.set(node.id, reverseEdges.get(node.id) ?? new Set());
83
104
 
84
105
  for (const cap of node.consumes) {
85
- const providerId = providerIndex.get(cap);
86
- // Skip self-edges (a module that consumes its own capability —
87
- // unusual but legal) and missing providers (deploy preflight
88
- // handles those; graph just routes around them).
89
- if (!providerId || providerId === node.id) continue;
90
-
91
- edges.get(node.id)?.add(providerId);
92
- const rev = reverseEdges.get(providerId) ?? new Set();
93
- rev.add(node.id);
94
- reverseEdges.set(providerId, rev);
106
+ for (const providerId of providerIndex.get(cap) ?? []) {
107
+ // Skip self-edges (a module that consumes its own capability —
108
+ // unusual but legal) and missing providers (deploy preflight
109
+ // handles those; graph just routes around them).
110
+ if (providerId === node.id) continue;
111
+
112
+ edges.get(node.id)?.add(providerId);
113
+ const rev = reverseEdges.get(providerId) ?? new Set();
114
+ rev.add(node.id);
115
+ reverseEdges.set(providerId, rev);
116
+ }
95
117
  }
96
118
  }
97
119
 
@@ -3,48 +3,11 @@
3
3
  * Auto-detects network zone from IP address by matching against system subnets
4
4
  */
5
5
 
6
+ import { subnetContains } from '@celilo/capabilities';
6
7
  import { eq } from 'drizzle-orm';
7
8
  import { getDb } from '../db/client';
8
9
  import { type NetworkZone, systemConfig } from '../db/schema';
9
10
 
10
- /**
11
- * Parse CIDR notation to get network address and prefix length
12
- */
13
- function parseCIDR(cidr: string): { network: string; prefixLength: number } {
14
- const [ip, prefix] = cidr.split('/');
15
- return {
16
- network: ip,
17
- prefixLength: Number.parseInt(prefix, 10),
18
- };
19
- }
20
-
21
- /**
22
- * Convert IP address string to 32-bit integer
23
- */
24
- function ipToInt(ip: string): number {
25
- const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
26
- return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
27
- }
28
-
29
- /**
30
- * Check if an IP address is in a CIDR subnet
31
- */
32
- function ipInSubnet(ip: string, cidr: string): boolean {
33
- try {
34
- const { network, prefixLength } = parseCIDR(cidr);
35
- const ipInt = ipToInt(ip);
36
- const networkInt = ipToInt(network);
37
-
38
- // Create subnet mask
39
- const mask = ~((1 << (32 - prefixLength)) - 1);
40
-
41
- // Check if IP is in subnet
42
- return (ipInt & mask) === (networkInt & mask);
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
11
  /**
49
12
  * Get system network configuration for a zone
50
13
  */
@@ -77,7 +40,7 @@ export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
77
40
 
78
41
  for (const zone of zones) {
79
42
  const subnet = await getZoneSubnet(zone);
80
- if (subnet && ipInSubnet(ip, subnet)) {
43
+ if (subnet && subnetContains(subnet, ip)) {
81
44
  return zone;
82
45
  }
83
46
  }
@@ -6,13 +6,17 @@ import { execSync } from 'node:child_process';
6
6
  * Disconnects stdin to ensure CLI cannot prompt for user input.
7
7
  * Any attempt to read from stdin will immediately fail, preventing hanging tests.
8
8
  *
9
- * When command fails, throws error with stderr included in message for test assertions.
9
+ * When command fails, throws an error carrying stderr where the CLI writes
10
+ * its diagnostics. Both helpers used to concatenate stdout and stderr because
11
+ * `@clack/prompts` put error messages on stdout, which left no way to tell a
12
+ * result from a complaint about producing one (celilo#699). The streams are
13
+ * separate now and this reads only the one that carries diagnostics.
10
14
  *
11
15
  * @param cli - CLI command prefix (includes env vars and path)
12
16
  * @param command - Command to execute (e.g., "system config get")
13
17
  * @param options - Additional exec options
14
- * @returns Command output as string
15
- * @throws Error with stderr message when command fails
18
+ * @returns Command stdout as string
19
+ * @throws Error with the stderr message when command fails
16
20
  */
17
21
  export function runCli(cli: string, command: string, options: { encoding?: 'utf-8' } = {}): string {
18
22
  try {
@@ -22,34 +26,31 @@ export function runCli(cli: string, command: string, options: { encoding?: 'utf-
22
26
  timeout: 30000, // 30 second timeout to prevent tests hanging indefinitely
23
27
  });
24
28
  } catch (error: unknown) {
25
- // CLI writes errors to stdout (via @clack/prompts), so capture both
26
- const stdout = (error as { stdout?: Buffer }).stdout?.toString() || '';
27
29
  const stderr = (error as { stderr?: Buffer }).stderr?.toString() || '';
28
- // Combine both stdout and stderr (some commands write to both)
29
- const output = [stdout, stderr].filter(Boolean).join('\n') || (error as Error).message;
30
- throw new Error(output);
30
+ throw new Error(stderr.trim() || (error as Error).message);
31
31
  }
32
32
  }
33
33
 
34
34
  /**
35
35
  * Execute CLI command expecting failure
36
- * Returns combined stdout + stderr for assertion
36
+ * Returns stderr for assertion that is where the CLI writes diagnostics.
37
37
  *
38
38
  * Disconnects stdin to ensure CLI cannot prompt for user input.
39
39
  *
40
40
  * @param cli - CLI command prefix
41
41
  * @param command - Command to execute
42
- * @returns combined output as string
42
+ * @returns stderr as string
43
43
  * @throws Error if command succeeds (when it should fail)
44
44
  */
45
45
  export function runCliExpectingFailure(cli: string, command: string): string {
46
46
  try {
47
47
  execSync(`${cli} ${command}`, { stdio: ['ignore', 'pipe', 'pipe'] });
48
- throw new Error('Expected command to fail but it succeeded');
49
48
  } catch (error: unknown) {
50
- // CLI writes errors to stdout (via @clack/prompts), so capture both
51
- const stdout = (error as { stdout?: Buffer }).stdout?.toString() || '';
52
49
  const stderr = (error as { stderr?: Buffer }).stderr?.toString() || '';
53
- return [stdout, stderr].filter(Boolean).join('\n') || (error as Error).message;
50
+ return stderr.trim() || (error as Error).message;
54
51
  }
52
+ // Previously thrown from inside the `try`, where the catch below swallowed it
53
+ // and RETURNED the message — so a command that wrongly succeeded read as a
54
+ // passing assertion about its own failure.
55
+ throw new Error('Expected command to fail but it succeeded');
55
56
  }
@@ -31,3 +31,29 @@ export function skipIntegration(
31
31
  if (req.platform && process.platform !== req.platform) return true;
32
32
  return false;
33
33
  }
34
+
35
+ /**
36
+ * Quarantine a test that FAILS ON THE CI RUNNER for a reason we have not yet
37
+ * explained, while keeping it running for developers locally.
38
+ *
39
+ * This is the `cele2e-ci-unsafe` device (see `--ci-safe` in the cele2e runner)
40
+ * applied to the integration suite, and it is deliberately uncomfortable to
41
+ * use: the caller must pass the tracking issue, and the whole point is that the
42
+ * skip is legible in the log rather than silent.
43
+ *
44
+ * Use this ONLY when the failure is not explained by a missing tool — reach for
45
+ * `skipIntegration({ tools: [...] })` first, because "which tool" is a real
46
+ * diagnosis and "quarantined" is an admission that we do not have one yet.
47
+ *
48
+ * The alternative is worse in both directions: leaving the test in makes a gate
49
+ * permanently red, which trains everyone to ignore it — the same disease as a
50
+ * gate that cannot fail. Deleting it loses the coverage and the evidence.
51
+ *
52
+ * Keyed on `GITHUB_ACTIONS`, which the Forgejo runner sets (it is what makes
53
+ * `bun test` emit `::error` annotations there).
54
+ *
55
+ * Usage: test.skipIf(quarantinedInCi('celilo#713'))('...', ...)
56
+ */
57
+ export function quarantinedInCi(_trackingIssue: string): boolean {
58
+ return process.env.GITHUB_ACTIONS === 'true';
59
+ }