@celilo/cli 1.12.0 → 1.14.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 (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
package/src/cli/index.ts CHANGED
@@ -25,6 +25,7 @@ import { handleCapabilityInfo } from './commands/capability-info';
25
25
  import { handleCapabilityList } from './commands/capability-list';
26
26
  import { handleCommands } from './commands/commands-json';
27
27
  import { handleCompletion } from './commands/completion';
28
+ import { handleConsoleGet, handleConsoleStatus } from './commands/console';
28
29
  import { handleDnsRegistrations } from './commands/dns';
29
30
  import {
30
31
  handleEventsAck,
@@ -123,6 +124,7 @@ import { handleSystemAudit } from './commands/system-audit';
123
124
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
124
125
  import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
125
126
  import { handleSystemDoctor } from './commands/system-doctor';
127
+ import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
126
128
  import { handleSystemInit } from './commands/system-init';
127
129
  import { handleSystemMigrate } from './commands/system-migrate';
128
130
  import { handleSystemSecretGet } from './commands/system-secret-get';
@@ -225,6 +227,7 @@ Commands:
225
227
  api Manage remote-API access (principals, grants, authorized_keys)
226
228
  completion Generate shell completion scripts (bash/zsh)
227
229
  commands Print the CLI command registry as JSON (drives @celilo/mcp)
230
+ console Narrow read-only projections for the web console
228
231
 
229
232
  help, --help, -h Show this help message
230
233
 
@@ -803,7 +806,11 @@ Subcommands:
803
806
 
804
807
  list [module-id] List available backups
805
808
  Options:
806
- --limit <n> Number of backups to show (default: 20)
809
+ --limit <n> Number of backups to show (default: 20; 5000 with --json)
810
+ --json Emit the listing as JSON with EXACT timestamps.
811
+ The human output buckets ages past six days into
812
+ "last week", so it cannot be parsed back into days.
813
+ --since <days> Only attempts from the last N days
807
814
 
808
815
  restore <backup-id> Restore from a backup
809
816
  Options:
@@ -1116,6 +1123,7 @@ Subcommands:
1116
1123
  to define, not a module's.
1117
1124
 
1118
1125
  discover-network Record the network this box sits on, from its own routing table (idempotent)
1126
+ ensure-fleet-key Mint celilo's fleet SSH key if absent, record ssh.public_key, print the public half
1119
1127
 
1120
1128
  config set <key> <value> Set system-wide configuration value
1121
1129
  config get [key] Get system configuration value(s)
@@ -1275,6 +1283,24 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1275
1283
  return handleCommands(parsed.args, parsed.flags);
1276
1284
  }
1277
1285
 
1286
+ // The web console's narrow reads.
1287
+ if (parsed.command === 'console') {
1288
+ const flagError = checkFlags('console', parsed.subcommand, parsed.flags, parsed.args);
1289
+ if (flagError) return flagError;
1290
+
1291
+ switch (parsed.subcommand) {
1292
+ case 'status':
1293
+ return handleConsoleStatus(parsed.flags);
1294
+ case 'get':
1295
+ return handleConsoleGet(parsed.args, parsed.flags);
1296
+ default:
1297
+ return {
1298
+ success: false,
1299
+ error: 'Console subcommand required: status | get',
1300
+ };
1301
+ }
1302
+ }
1303
+
1278
1304
  // Top-level alias: `celilo audit` → `celilo system audit`
1279
1305
  if (parsed.command === 'audit') {
1280
1306
  return handleSystemAudit(parsed.args, parsed.flags);
@@ -2197,6 +2223,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2197
2223
  return handleSystemDiscoverNetwork();
2198
2224
  }
2199
2225
 
2226
+ if (parsed.subcommand === 'ensure-fleet-key') {
2227
+ return handleSystemEnsureFleetKey();
2228
+ }
2229
+
2200
2230
  if (parsed.subcommand === 'config') {
2201
2231
  const configSubcommand = parsed.args[0];
2202
2232
  if (!configSubcommand) {
@@ -0,0 +1,322 @@
1
+ /**
2
+ * The six graph shapes the plan names, five of which the live fleet produced.
3
+ *
4
+ * They are here rather than in an e2e suite because every one of them is a
5
+ * property of the walk, and a walk is testable without a fleet.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import type { ModuleManifest } from '../manifest/schema';
9
+ import type { ProviderRow, ProviderState } from '../services/consumer-cleanup';
10
+ import { DEFAULT_CLOSURE_DEPTH, UNBOUNDED_CLOSURE_DEPTH, computeClosure } from './closure';
11
+
12
+ /** Only the two capability lists matter here; the rest of a manifest does not. */
13
+ function manifest(spec: { requires?: string[]; optional?: string[] }): ModuleManifest {
14
+ return {
15
+ requires: { capabilities: (spec.requires ?? []).map((name) => ({ name })) },
16
+ optional: { capabilities: (spec.optional ?? []).map((name) => ({ name })) },
17
+ } as unknown as ModuleManifest;
18
+ }
19
+
20
+ function deployed(...moduleIds: string[]): ProviderState[] {
21
+ return moduleIds.map((moduleId) => ({ moduleId, state: 'VERIFIED' }));
22
+ }
23
+
24
+ describe('computeClosure', () => {
25
+ test('follows an optional-only edge, and marks it optional', () => {
26
+ // The live case: a module reaches the internal resolver ONLY optionally.
27
+ // Following `requires` alone drops the resolver while looking complete.
28
+ const rows: ProviderRow[] = [{ moduleId: 'technitium', capabilityName: 'dns_internal' }];
29
+ const result = computeClosure({
30
+ rootModuleId: 'wireguard-manager',
31
+ manifests: new Map([['wireguard-manager', manifest({ optional: ['dns_internal'] })]]),
32
+ providerRows: rows,
33
+ providerStates: deployed('technitium'),
34
+ });
35
+
36
+ expect(result.nodes).toHaveLength(1);
37
+ expect(result.nodes[0]?.moduleId).toBe('technitium');
38
+ expect(result.nodes[0]?.optional).toBe(true);
39
+ });
40
+
41
+ test('a module reached both ways is not optional', () => {
42
+ const rows: ProviderRow[] = [
43
+ { moduleId: 'caddy', capabilityName: 'private_web' },
44
+ { moduleId: 'caddy', capabilityName: 'public_web' },
45
+ ];
46
+ const result = computeClosure({
47
+ rootModuleId: 'app',
48
+ manifests: new Map([
49
+ ['app', manifest({ requires: ['private_web'], optional: ['public_web'] })],
50
+ ]),
51
+ providerRows: rows,
52
+ providerStates: deployed('caddy'),
53
+ });
54
+
55
+ expect(result.nodes[0]?.optional).toBe(false);
56
+ expect(result.nodes[0]?.via).toEqual(['private_web', 'public_web']);
57
+ });
58
+
59
+ test('the default depth excludes a three-hop dependency', () => {
60
+ const rows: ProviderRow[] = [
61
+ { moduleId: 'b', capabilityName: 'cap_b' },
62
+ { moduleId: 'c', capabilityName: 'cap_c' },
63
+ { moduleId: 'd', capabilityName: 'cap_d' },
64
+ ];
65
+ const manifests = new Map([
66
+ ['a', manifest({ requires: ['cap_b'] })],
67
+ ['b', manifest({ requires: ['cap_c'] })],
68
+ ['c', manifest({ requires: ['cap_d'] })],
69
+ ['d', manifest({})],
70
+ ]);
71
+ const result = computeClosure({
72
+ rootModuleId: 'a',
73
+ manifests,
74
+ providerRows: rows,
75
+ providerStates: deployed('b', 'c', 'd'),
76
+ });
77
+
78
+ expect(result.depth).toBe(DEFAULT_CLOSURE_DEPTH);
79
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['b', 'c']);
80
+ expect(result.truncated).toBe(true);
81
+ });
82
+
83
+ test('an unbounded walk terminates on a cycle, listing each module once', () => {
84
+ // caddy requires authentik, authentik requires caddy. The real graph.
85
+ const rows: ProviderRow[] = [
86
+ { moduleId: 'authentik', capabilityName: 'idp' },
87
+ { moduleId: 'caddy', capabilityName: 'private_web' },
88
+ ];
89
+ const manifests = new Map([
90
+ ['caddy', manifest({ requires: ['idp'] })],
91
+ ['authentik', manifest({ requires: ['private_web'] })],
92
+ ]);
93
+ const result = computeClosure({
94
+ rootModuleId: 'caddy',
95
+ manifests,
96
+ providerRows: rows,
97
+ providerStates: deployed('caddy', 'authentik'),
98
+ depth: UNBOUNDED_CLOSURE_DEPTH,
99
+ });
100
+
101
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['authentik']);
102
+ expect(result.truncated).toBe(false);
103
+ });
104
+
105
+ test('a capability with several providers highlights every one', () => {
106
+ // firewall is deliberately multi-provider: an edge provider plus inner layers.
107
+ const rows: ProviderRow[] = [
108
+ { moduleId: 'iptables', capabilityName: 'firewall' },
109
+ { moduleId: 'opnsense', capabilityName: 'firewall' },
110
+ ];
111
+ const result = computeClosure({
112
+ rootModuleId: 'app',
113
+ manifests: new Map([['app', manifest({ requires: ['firewall'] })]]),
114
+ providerRows: rows,
115
+ providerStates: deployed('iptables', 'opnsense'),
116
+ });
117
+
118
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'opnsense']);
119
+ });
120
+
121
+ test('a module that depends on nothing returns an empty closure, not a failure', () => {
122
+ const result = computeClosure({
123
+ rootModuleId: 'standalone',
124
+ manifests: new Map([['standalone', manifest({})]]),
125
+ providerRows: [],
126
+ providerStates: [],
127
+ });
128
+
129
+ expect(result.nodes).toEqual([]);
130
+ expect(result.truncated).toBe(false);
131
+ });
132
+
133
+ test('a module that provides what it consumes is not its own dependency', () => {
134
+ const rows: ProviderRow[] = [
135
+ { moduleId: 'caddy', capabilityName: 'private_web' },
136
+ { moduleId: 'other', capabilityName: 'private_web' },
137
+ ];
138
+ const result = computeClosure({
139
+ rootModuleId: 'caddy',
140
+ manifests: new Map([['caddy', manifest({ requires: ['private_web'] })]]),
141
+ providerRows: rows,
142
+ providerStates: deployed('caddy', 'other'),
143
+ });
144
+
145
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['other']);
146
+ });
147
+
148
+ test('the shortest hop wins when a module is reachable two ways', () => {
149
+ const rows: ProviderRow[] = [
150
+ { moduleId: 'b', capabilityName: 'cap_b' },
151
+ { moduleId: 'shared', capabilityName: 'cap_shared' },
152
+ { moduleId: 'shared', capabilityName: 'cap_deep' },
153
+ ];
154
+ const manifests = new Map([
155
+ ['a', manifest({ requires: ['cap_b', 'cap_shared'] })],
156
+ ['b', manifest({ requires: ['cap_deep'] })],
157
+ ['shared', manifest({})],
158
+ ]);
159
+ const result = computeClosure({
160
+ rootModuleId: 'a',
161
+ manifests,
162
+ providerRows: rows,
163
+ providerStates: deployed('b', 'shared'),
164
+ });
165
+
166
+ const shared = result.nodes.find((n) => n.moduleId === 'shared');
167
+ expect(shared?.hop).toBe(1);
168
+ });
169
+ });
170
+
171
+ describe('the walk follows real bindings, not declarations', () => {
172
+ const rows: ProviderRow[] = [
173
+ { moduleId: 'cpanel', capabilityName: 'external_web' },
174
+ { moduleId: 'caddy', capabilityName: 'public_web' },
175
+ { moduleId: 'forgejo', capabilityName: 'source_forge' },
176
+ ];
177
+ const manifests = new Map([
178
+ ['site', manifest({ optional: ['external_web', 'public_web', 'source_forge'] })],
179
+ ['cpanel', manifest({})],
180
+ ['caddy', manifest({})],
181
+ ['forgejo', manifest({})],
182
+ ]);
183
+
184
+ test('a declared capability the module never called into is NOT a dependency', () => {
185
+ // The tango-nexus case, which is what celilo#1072 was filed for: four
186
+ // optional declarations, one actual binding. Following the declarations
187
+ // claims the module is standing on three things it has never touched.
188
+ const result = computeClosure({
189
+ rootModuleId: 'site',
190
+ manifests,
191
+ providerRows: rows,
192
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
193
+ bindings: new Map([['site', new Map([['external_web', 'cpanel']])]]),
194
+ });
195
+
196
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['cpanel']);
197
+ });
198
+
199
+ test('without bindings the walk falls back to declarations, and says more', () => {
200
+ // Not a silent fallback: the caller decides. A walk with no binding data
201
+ // answers "what could this reach", which is a different and still useful
202
+ // question, and it must not be mistaken for the other one.
203
+ const result = computeClosure({
204
+ rootModuleId: 'site',
205
+ manifests,
206
+ providerRows: rows,
207
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
208
+ });
209
+
210
+ expect(result.nodes.map((n) => n.moduleId).sort()).toEqual(['caddy', 'cpanel', 'forgejo']);
211
+ });
212
+
213
+ test('a binding to a DIFFERENT provider of the same capability is not followed', () => {
214
+ // firewall has several live providers. A consumer bound to one of them is
215
+ // not thereby standing on the others.
216
+ const multi: ProviderRow[] = [
217
+ { moduleId: 'iptables', capabilityName: 'firewall' },
218
+ { moduleId: 'axon', capabilityName: 'firewall' },
219
+ ];
220
+ const result = computeClosure({
221
+ rootModuleId: 'app',
222
+ manifests: new Map([
223
+ ['app', manifest({ requires: ['firewall'] })],
224
+ ['iptables', manifest({})],
225
+ ['axon', manifest({})],
226
+ ]),
227
+ providerRows: multi,
228
+ providerStates: deployed('iptables', 'axon'),
229
+ bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
230
+ });
231
+
232
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables']);
233
+ });
234
+
235
+ test('a delegated provider is reached, though no manifest names it', () => {
236
+ // The case the whole chain exists for. `app` is BOUND to iptables, so the
237
+ // manifest walk stops there and the ISP router the packet actually leaves
238
+ // through never appears — even though iptables cannot reach the internet
239
+ // without it. celilo wires that edge at hook time; nothing declares it.
240
+ const multi: ProviderRow[] = [
241
+ { moduleId: 'iptables', capabilityName: 'firewall' },
242
+ { moduleId: 'axon', capabilityName: 'firewall' },
243
+ ];
244
+ const result = computeClosure({
245
+ rootModuleId: 'app',
246
+ manifests: new Map([
247
+ ['app', manifest({ requires: ['firewall'] })],
248
+ ['iptables', manifest({})],
249
+ ['axon', manifest({})],
250
+ ]),
251
+ providerRows: multi,
252
+ providerStates: deployed('iptables', 'axon'),
253
+ bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
254
+ chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
255
+ });
256
+
257
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'axon']);
258
+ // Hop 2, because it is reached THROUGH iptables. Not a second hop-1
259
+ // dependency of the selection, which is what a fan would say.
260
+ expect(result.nodes.find((n) => n.moduleId === 'axon')?.hop).toBe(2);
261
+ // A packet has no alternative route to the internet.
262
+ expect(result.nodes.find((n) => n.moduleId === 'axon')?.optional).toBe(false);
263
+ expect(result.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
264
+ });
265
+
266
+ test('a chain the walk never touched is not reported', () => {
267
+ // The chain is a fleet fact and the closure is about one module. A selection
268
+ // that consumes no firewall stands on none of it.
269
+ const result = computeClosure({
270
+ rootModuleId: 'site',
271
+ manifests,
272
+ providerRows: rows,
273
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
274
+ chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
275
+ });
276
+
277
+ expect(result.chains).toEqual([]);
278
+ });
279
+
280
+ test('the depth bound cuts a delegated provider like any other', () => {
281
+ // Three layers, bounded at two, so the LAST link is the one the bound cuts.
282
+ // The delegation edges are walked inside the same loop as the declared ones
283
+ // and get the same bound; a rule applied after the walk would smuggle the
284
+ // whole chain into a two-hop answer.
285
+ //
286
+ // A two-layer chain would not test this. `iptables` is the only declared
287
+ // dependency, so the walk exhausts at hop 2 whether or not the delegation
288
+ // is followed, and the assertion would hold for the wrong reason.
289
+ const result = computeClosure({
290
+ rootModuleId: 'app',
291
+ manifests: new Map([
292
+ ['app', manifest({ requires: ['firewall'] })],
293
+ ['iptables', manifest({})],
294
+ ['mid', manifest({})],
295
+ ['axon', manifest({})],
296
+ ]),
297
+ providerRows: [{ moduleId: 'iptables', capabilityName: 'firewall' }],
298
+ providerStates: deployed('iptables', 'mid', 'axon'),
299
+ depth: 2,
300
+ chains: [{ capability: 'firewall', moduleIds: ['iptables', 'mid', 'axon'] }],
301
+ });
302
+
303
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'mid']);
304
+ expect(result.truncated).toBe(true);
305
+ // Still reported WHOLE, including the member past the bound. Half a path is
306
+ // not a path, and the operator asked to see less, not to be told the packet
307
+ // stops at `mid`.
308
+ expect(result.chains[0]?.moduleIds).toEqual(['iptables', 'mid', 'axon']);
309
+ });
310
+
311
+ test('a module with no bindings at all depends on nothing', () => {
312
+ const result = computeClosure({
313
+ rootModuleId: 'site',
314
+ manifests,
315
+ providerRows: rows,
316
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
317
+ bindings: new Map([['site', new Map()]]),
318
+ });
319
+
320
+ expect(result.nodes).toEqual([]);
321
+ });
322
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The bounded capability closure: which modules a selected module stands on.
3
+ *
4
+ * Pure, so the console's central question is answerable without a database and
5
+ * the awkward graph shapes below are testable directly.
6
+ *
7
+ * The edge is `planConsumerCleanup()`'s edge, called rather than copied. That
8
+ * function already answers "which providers does this module reach", already
9
+ * counts `requires` AND `optional` as dependency edges, and already handles the
10
+ * two cases a fresh implementation gets wrong: a capability with several
11
+ * providers (firewall has an edge provider plus inner layers, all of them real)
12
+ * and a module that both provides and consumes a capability, which must not
13
+ * become its own dependency. A second walk would drift from it.
14
+ *
15
+ * What this adds is the part a one-hop cleanup plan has no use for: distance,
16
+ * optionality, and termination.
17
+ */
18
+
19
+ import {
20
+ type ConsumedCapabilities,
21
+ type ProviderRow,
22
+ type ProviderState,
23
+ planConsumerCleanup,
24
+ } from '../services/consumer-cleanup';
25
+
26
+ /**
27
+ * Hops to walk when the caller does not say.
28
+ *
29
+ * Two, because the fleet's useful answer is "what this stands on, and what that
30
+ * stands on". Deliberately a default and not a law: `depth: UNBOUNDED` walks the
31
+ * whole graph, and the walk terminates on cycles either way.
32
+ */
33
+ export const DEFAULT_CLOSURE_DEPTH = 2;
34
+
35
+ /** Walk until the graph runs out rather than until a hop count does. */
36
+ export const UNBOUNDED_CLOSURE_DEPTH = 0;
37
+
38
+ export interface ClosureRequest {
39
+ rootModuleId: string;
40
+ /**
41
+ * Each module's consumed capabilities, by module id. A module with no entry
42
+ * ends that branch of the walk rather than failing the whole thing: a manifest
43
+ * celilo cannot parse is a real state, and one bad row must not blank the
44
+ * picture.
45
+ */
46
+ manifests: ReadonlyMap<string, ConsumedCapabilities>;
47
+ providerRows: readonly ProviderRow[];
48
+ providerStates: readonly ProviderState[];
49
+ depth?: number;
50
+ /**
51
+ * Which providers each module has actually called into, keyed by module id
52
+ * then capability name.
53
+ *
54
+ * When supplied, the walk follows only real bindings. Without it the walk
55
+ * follows every declared capability, which answers "what could this reach"
56
+ * rather than "what is this standing on". Optional so the walk stays testable
57
+ * without a database, not so the distinction is optional.
58
+ */
59
+ bindings?: ReadonlyMap<string, ReadonlyMap<string, string>>;
60
+ /**
61
+ * Ordered provider chains, most downstream first.
62
+ *
63
+ * These are edges NO MANIFEST DECLARES. celilo wires the `firewall`
64
+ * providers into a chain at hook time from `has_external`, so `iptables`
65
+ * genuinely stands on `axon` while neither manifest says a word about the
66
+ * other. A walk over manifests alone stops at `iptables`, and the box the
67
+ * packet actually leaves through never appears in the answer.
68
+ *
69
+ * Supplied by the caller rather than derived here so this stays pure. The CLI
70
+ * reads them with `loadFirewallChain`.
71
+ */
72
+ chains?: readonly DelegationChain[];
73
+ }
74
+
75
+ /**
76
+ * One capability's providers in delegation order.
77
+ *
78
+ * Ordered because for `firewall` they are not alternatives: the order is the
79
+ * packet's path. Same shape as the console protocol's `DelegationChain`, so the
80
+ * server hands it on rather than translating it.
81
+ */
82
+ export interface DelegationChain {
83
+ capability: string;
84
+ /** Module ids, most downstream first. */
85
+ moduleIds: string[];
86
+ }
87
+
88
+ export interface ClosureNode {
89
+ moduleId: string;
90
+ /** 1 is a direct dependency of the root. */
91
+ hop: number;
92
+ /**
93
+ * True when every edge that reaches this module is an `optional.capabilities`
94
+ * edge.
95
+ *
96
+ * Load-bearing rather than cosmetic: on a real fleet the internal DNS resolver
97
+ * is reached only optionally, so a walk that followed `requires` alone drops
98
+ * the resolver out of the picture entirely while looking complete.
99
+ */
100
+ optional: boolean;
101
+ /** Capability names by which the walk reached this module, sorted. */
102
+ via: string[];
103
+ }
104
+
105
+ export interface ClosureResult {
106
+ nodes: ClosureNode[];
107
+ /**
108
+ * The delegation chains this closure touched, whole.
109
+ *
110
+ * A chain is emitted when the walk reached ANY of its members, and it carries
111
+ * every member rather than only the reached ones, because half a path is not
112
+ * a path. Empty means the closure touched no chain — which, now that
113
+ * something computes them, is an answer rather than a gap.
114
+ */
115
+ chains: DelegationChain[];
116
+ /** Hops actually walked. Echoes the request so the console can say what it bounded. */
117
+ depth: number;
118
+ /**
119
+ * True when the walk stopped because it hit the bound and the graph continued.
120
+ * Distinct from an exhausted graph, which is a complete answer.
121
+ */
122
+ truncated: boolean;
123
+ }
124
+
125
+ interface Edge {
126
+ to: string;
127
+ hop: number;
128
+ capability: string;
129
+ optional: boolean;
130
+ }
131
+
132
+ /**
133
+ * Which of a module's consumed capabilities it declared as optional.
134
+ *
135
+ * A name in both sets counts as required: `requires` is the stronger claim, and
136
+ * a module that declares a capability both ways still fails without it.
137
+ */
138
+ function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
139
+ const required = new Set((manifest.requires?.capabilities ?? []).map((c) => c.name));
140
+ return new Set(
141
+ (manifest.optional?.capabilities ?? []).map((c) => c.name).filter((n) => !required.has(n)),
142
+ );
143
+ }
144
+
145
+ /**
146
+ * A chain's consecutive pairs, as edges out of each member.
147
+ *
148
+ * `[iptables, axon]` becomes one edge `iptables -> axon`. The last member has
149
+ * no edge out of it: it is the internet-facing end and delegates to nothing.
150
+ */
151
+ function delegationEdgesByModule(
152
+ chains: readonly DelegationChain[],
153
+ ): Map<string, { to: string; capability: string }[]> {
154
+ const byModule = new Map<string, { to: string; capability: string }[]>();
155
+ for (const chain of chains) {
156
+ for (let i = 0; i + 1 < chain.moduleIds.length; i += 1) {
157
+ const from = chain.moduleIds[i];
158
+ const to = chain.moduleIds[i + 1];
159
+ if (from === undefined || to === undefined) continue;
160
+ const out = byModule.get(from) ?? [];
161
+ out.push({ to, capability: chain.capability });
162
+ byModule.set(from, out);
163
+ }
164
+ }
165
+ return byModule;
166
+ }
167
+
168
+ export function computeClosure(request: ClosureRequest): ClosureResult {
169
+ const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
170
+ const providerRows = [...request.providerRows];
171
+ const providerStates = [...request.providerStates];
172
+ const chains = request.chains ?? [];
173
+ const delegationsOut = delegationEdgesByModule(chains);
174
+
175
+ const edges: Edge[] = [];
176
+ // The root counts as visited from the start, so a cycle back to it terminates
177
+ // rather than re-entering, and the selection never lists itself.
178
+ const visited = new Set([request.rootModuleId]);
179
+ let frontier = [request.rootModuleId];
180
+ let hop = 0;
181
+ let truncated = false;
182
+
183
+ while (frontier.length > 0) {
184
+ hop += 1;
185
+ if (depth !== UNBOUNDED_CLOSURE_DEPTH && hop > depth) {
186
+ // Something was still reachable when the bound stopped us. That is a
187
+ // different answer from an exhausted graph and the console says so.
188
+ truncated = true;
189
+ break;
190
+ }
191
+
192
+ const next: string[] = [];
193
+ for (const moduleId of frontier) {
194
+ const manifest = request.manifests.get(moduleId);
195
+ if (!manifest) continue;
196
+
197
+ const optionalNames = optionalCapabilityNames(manifest);
198
+ const bound = request.bindings?.get(moduleId);
199
+
200
+ for (const target of planConsumerCleanup(moduleId, manifest, providerRows, providerStates)) {
201
+ // A cycle walks back to the selection. Terminating the WALK there is not
202
+ // enough: the edge is still real, and recording it would list the
203
+ // selected module as one of its own dependencies. caddy requires
204
+ // authentik and authentik requires caddy, so this is the ordinary case
205
+ // on a real fleet rather than a pathological one.
206
+ if (target.providerId === request.rootModuleId) continue;
207
+
208
+ for (const capability of target.capabilityNames) {
209
+ // A capability the consumer declared and never called into is not a
210
+ // dependency. Following it would put a provider inside the closure on
211
+ // the strength of a manifest line, which is a claim that the module is
212
+ // standing on something it has never touched.
213
+ if (bound && bound.get(capability) !== target.providerId) continue;
214
+
215
+ edges.push({
216
+ to: target.providerId,
217
+ hop,
218
+ capability,
219
+ optional: optionalNames.has(capability),
220
+ });
221
+ }
222
+ if (!visited.has(target.providerId)) {
223
+ visited.add(target.providerId);
224
+ next.push(target.providerId);
225
+ }
226
+ }
227
+ }
228
+
229
+ // The edges the manifests do not carry. Followed here, inside the same loop,
230
+ // so a delegated provider gets its hop, its cycle check and its depth bound
231
+ // from the one walk rather than from a second rule applied afterwards.
232
+ //
233
+ // Never optional: a packet has no alternative route to the internet, so a
234
+ // delegation is the strongest kind of dependency there is.
235
+ for (const moduleId of frontier) {
236
+ for (const edge of delegationsOut.get(moduleId) ?? []) {
237
+ if (edge.to === request.rootModuleId) continue;
238
+ edges.push({ to: edge.to, hop, capability: edge.capability, optional: false });
239
+ if (!visited.has(edge.to)) {
240
+ visited.add(edge.to);
241
+ next.push(edge.to);
242
+ }
243
+ }
244
+ }
245
+
246
+ frontier = next;
247
+ }
248
+
249
+ const nodes = collectNodes(edges);
250
+ const reached = new Set([request.rootModuleId, ...nodes.map((node) => node.moduleId)]);
251
+
252
+ return {
253
+ nodes,
254
+ chains: chains.filter((chain) => chain.moduleIds.some((id) => reached.has(id))),
255
+ depth,
256
+ truncated,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Fold the edge list into one node per module.
262
+ *
263
+ * A module is recorded at the SHORTEST hop that reached it, and is optional only
264
+ * if no edge anywhere in the walk reached it by a required capability. Deciding
265
+ * either from the first edge seen would make the answer depend on iteration
266
+ * order.
267
+ */
268
+ function collectNodes(edges: readonly Edge[]): ClosureNode[] {
269
+ const byModule = new Map<string, { hop: number; via: Set<string>; anyRequired: boolean }>();
270
+
271
+ for (const edge of edges) {
272
+ const existing = byModule.get(edge.to);
273
+ if (!existing) {
274
+ byModule.set(edge.to, {
275
+ hop: edge.hop,
276
+ via: new Set([edge.capability]),
277
+ anyRequired: !edge.optional,
278
+ });
279
+ continue;
280
+ }
281
+ existing.hop = Math.min(existing.hop, edge.hop);
282
+ existing.via.add(edge.capability);
283
+ existing.anyRequired = existing.anyRequired || !edge.optional;
284
+ }
285
+
286
+ return [...byModule.entries()]
287
+ .map(([moduleId, v]) => ({
288
+ moduleId,
289
+ hop: v.hop,
290
+ optional: !v.anyRequired,
291
+ via: [...v.via].sort(),
292
+ }))
293
+ .sort((a, b) => a.hop - b.hop || a.moduleId.localeCompare(b.moduleId));
294
+ }