@celilo/cli 1.13.0 → 2.0.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 (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `celilo system ensure-fleet-key` — mint celilo's fleet SSH keypair if it
3
+ * is absent, record its public half, and print it.
4
+ *
5
+ * The celilo-side half of "minting the fleet key is not the module's job"
6
+ * (openspec/changes/hook-process-boundary, design D9b). celilo-mgmt's
7
+ * `on_install` used to generate the keypair itself, writing into celilo's
8
+ * data directory from inside a hook — the one directory the hook jail
9
+ * exists to keep out of the mount set. The hook now asks for the key
10
+ * through this command and receives the public half, rather than producing
11
+ * it.
12
+ *
13
+ * Idempotent: an existing key is reused. Re-keying would strand every
14
+ * machine whose authorized_keys holds the old public half.
15
+ */
16
+
17
+ import { getDb } from '../../db/client';
18
+ import { ensureFleetKey, getFleetSshDir } from '../../services/fleet-key';
19
+ import { initializeSystem } from '../../services/system-init';
20
+ import type { CommandResult } from '../types';
21
+
22
+ export async function handleSystemEnsureFleetKey(): Promise<CommandResult> {
23
+ let key: ReturnType<typeof ensureFleetKey>;
24
+ try {
25
+ key = ensureFleetKey();
26
+ } catch (err) {
27
+ return {
28
+ success: false,
29
+ error: `Could not create the fleet SSH key in ${getFleetSshDir()}: ${
30
+ err instanceof Error ? err.message : String(err)
31
+ }`,
32
+ };
33
+ }
34
+
35
+ try {
36
+ initializeSystem(getDb(), { 'ssh.public_key': key.publicKey });
37
+ } catch (err) {
38
+ return {
39
+ success: false,
40
+ error: `Fleet key is on disk but recording ssh.public_key failed: ${
41
+ err instanceof Error ? err.message : String(err)
42
+ }`,
43
+ };
44
+ }
45
+
46
+ // The public key alone, and `rawOutput` to say so on the record: this
47
+ // command exists to be read by celilo-mgmt's on_install as much as by an
48
+ // operator, and the key IS the answer either way (an operator pastes it into
49
+ // a machine's authorized_keys). Wrapping it in prose would make the caller
50
+ // parse for it, which is how a hook ends up depending on a sentence.
51
+ return { success: true, message: key.publicKey, rawOutput: true };
52
+ }
@@ -8,6 +8,8 @@ import { getDb } from '../db/client';
8
8
  import { capabilities, modules } from '../db/schema';
9
9
  import type { ModuleManifest } from '../manifest/schema';
10
10
  import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
11
+ import { HEALTH_COVERAGE_CHECK } from '../services/alerting/health-coverage';
12
+ import { HOOK_JAIL_CHECK } from '../services/alerting/hook-jail';
11
13
  import { listPrincipals } from '../services/api-access';
12
14
  import { listBackups } from '../services/backup-metadata';
13
15
  import { listBackupStorages } from '../services/backup-storage';
@@ -532,7 +534,9 @@ export async function getCompletions(words: string[], current: number): Promise<
532
534
 
533
535
  // Monitor targets - a module ID or one of celilo's own schedulable checks.
534
536
  // Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
535
- // a new built-in check is completable the moment it is schedulable.
537
+ // a new built-in check is completable the moment it is schedulable. The two
538
+ // local-state checks (health coverage, hook jail) are deliberately not in
539
+ // that list — it is the audit categories — so they ride alongside by name.
536
540
  if (
537
541
  command === 'monitor' &&
538
542
  (args[1] === 'add' ||
@@ -548,7 +552,10 @@ export async function getCompletions(words: string[], current: number): Promise<
548
552
  .from(modules)
549
553
  .all()
550
554
  .map((m) => m.id);
551
- return filterSuggestions([...SCHEDULABLE_BUILTIN_CHECKS, ...moduleIds], args[2] || '');
555
+ return filterSuggestions(
556
+ [...SCHEDULABLE_BUILTIN_CHECKS, HEALTH_COVERAGE_CHECK, HOOK_JAIL_CHECK, ...moduleIds],
557
+ args[2] || '',
558
+ );
552
559
  }
553
560
 
554
561
  // Alerts subcommands
@@ -674,6 +681,7 @@ export async function getCompletions(words: string[], current: number): Promise<
674
681
  'init',
675
682
  'apply-config',
676
683
  'discover-network',
684
+ 'ensure-fleet-key',
677
685
  'config',
678
686
  'secret',
679
687
  'vault-password',
package/src/cli/index.ts CHANGED
@@ -124,6 +124,7 @@ import { handleSystemAudit } from './commands/system-audit';
124
124
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
125
125
  import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
126
126
  import { handleSystemDoctor } from './commands/system-doctor';
127
+ import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
127
128
  import { handleSystemInit } from './commands/system-init';
128
129
  import { handleSystemMigrate } from './commands/system-migrate';
129
130
  import { handleSystemSecretGet } from './commands/system-secret-get';
@@ -618,7 +619,7 @@ Subcommands:
618
619
  show-config <id> Show all config including auto-derived values
619
620
  show-zone <id> Show module's network zone and config
620
621
 
621
- build <module-id> Execute build scripts (Nix + Ansible)
622
+ build <module-id> Execute module build scripts (Ansible)
622
623
 
623
624
  generate <id> Generate infrastructure code for module
624
625
  Options:
@@ -1122,6 +1123,7 @@ Subcommands:
1122
1123
  to define, not a module's.
1123
1124
 
1124
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
1125
1127
 
1126
1128
  config set <key> <value> Set system-wide configuration value
1127
1129
  config get [key] Get system configuration value(s)
@@ -2221,6 +2223,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2221
2223
  return handleSystemDiscoverNetwork();
2222
2224
  }
2223
2225
 
2226
+ if (parsed.subcommand === 'ensure-fleet-key') {
2227
+ return handleSystemEnsureFleetKey();
2228
+ }
2229
+
2224
2230
  if (parsed.subcommand === 'config') {
2225
2231
  const configSubcommand = parsed.args[0];
2226
2232
  if (!configSubcommand) {
@@ -232,6 +232,82 @@ describe('the walk follows real bindings, not declarations', () => {
232
232
  expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables']);
233
233
  });
234
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
+
235
311
  test('a module with no bindings at all depends on nothing', () => {
236
312
  const result = computeClosure({
237
313
  rootModuleId: 'site',
@@ -57,6 +57,32 @@ export interface ClosureRequest {
57
57
  * without a database, not so the distinction is optional.
58
58
  */
59
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[];
60
86
  }
61
87
 
62
88
  export interface ClosureNode {
@@ -78,6 +104,15 @@ export interface ClosureNode {
78
104
 
79
105
  export interface ClosureResult {
80
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[];
81
116
  /** Hops actually walked. Echoes the request so the console can say what it bounded. */
82
117
  depth: number;
83
118
  /**
@@ -107,10 +142,35 @@ function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
107
142
  );
108
143
  }
109
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
+
110
168
  export function computeClosure(request: ClosureRequest): ClosureResult {
111
169
  const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
112
170
  const providerRows = [...request.providerRows];
113
171
  const providerStates = [...request.providerStates];
172
+ const chains = request.chains ?? [];
173
+ const delegationsOut = delegationEdgesByModule(chains);
114
174
 
115
175
  const edges: Edge[] = [];
116
176
  // The root counts as visited from the start, so a cycle back to it terminates
@@ -165,10 +225,36 @@ export function computeClosure(request: ClosureRequest): ClosureResult {
165
225
  }
166
226
  }
167
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
+
168
246
  frontier = next;
169
247
  }
170
248
 
171
- return { nodes: collectNodes(edges), depth, truncated };
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
+ };
172
258
  }
173
259
 
174
260
  /**
@@ -15,12 +15,12 @@
15
15
  * caddy-internal" for a further round after that. Prose did not hold this.
16
16
  */
17
17
  import { describe, expect, test } from 'bun:test';
18
+ import { readFileSync, readdirSync } from 'node:fs';
19
+ import { join } from 'node:path';
18
20
  import { parse } from 'yaml';
19
21
 
20
- const MANIFEST_PATH = new URL(
21
- '../../../../modules/celilo-web-console/manifest.yml',
22
- import.meta.url,
23
- ).pathname;
22
+ const MODULE_DIR = new URL('../../../../modules/celilo-web-console/', import.meta.url).pathname;
23
+ const MANIFEST_PATH = join(MODULE_DIR, 'manifest.yml');
24
24
 
25
25
  /**
26
26
  * Capabilities that give something a way in from the data plane.
@@ -73,3 +73,81 @@ describe('celilo-web-console stays in the control plane', () => {
73
73
  expect(manifest.provides?.capabilities ?? []).toEqual([]);
74
74
  });
75
75
  });
76
+
77
+ /**
78
+ * The build output the manifest's `build:` step writes, which is not source.
79
+ *
80
+ * Excluded from the walk below for two reasons, and the second is the one that
81
+ * matters. It holds two ~100MB compiled binaries, so reading it as text is
82
+ * absurd. And it holds a minified React bundle, which contains almost every
83
+ * short string you could think to search for — so scanning it reports ingress
84
+ * that nobody wrote. Both are gitignored, so on a fresh checkout the directory
85
+ * is absent entirely and the walk would silently be checking a different set of
86
+ * files than it checks on a machine that has run a build.
87
+ */
88
+ const BUILD_OUTPUT_DIR = 'files';
89
+
90
+ /**
91
+ * Every source file in the module's deployable body.
92
+ *
93
+ * A walk rather than a list, because the hole this guards is a file somebody
94
+ * ADDS. Listing today's files would pass over exactly the new Ansible task that
95
+ * opens the port.
96
+ */
97
+ function deployableBody(): { path: string; text: string }[] {
98
+ const found: { path: string; text: string }[] = [];
99
+ const walk = (dir: string) => {
100
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
101
+ if (entry.isDirectory()) {
102
+ if (entry.name !== BUILD_OUTPUT_DIR) walk(join(dir, entry.name));
103
+ continue;
104
+ }
105
+ const path = join(dir, entry.name);
106
+ found.push({ path, text: readFileSync(path, 'utf8') });
107
+ }
108
+ };
109
+ for (const sub of ['terraform', 'ansible']) walk(join(MODULE_DIR, sub));
110
+ return found;
111
+ }
112
+
113
+ /**
114
+ * How a deployable body would open a path in from the data plane.
115
+ *
116
+ * A reverse-proxy vhost, a DNAT or port-forward rule, a `natIp`, or a dedicated
117
+ * ingress address. None belongs on a `secure-mgmt` module, and every one is a
118
+ * plausible one-line addition by someone who wanted to reach the console
119
+ * without bringing up the VPN.
120
+ */
121
+ const INGRESS_TOKENS = [
122
+ 'caddy',
123
+ 'reverse_proxy',
124
+ 'natip',
125
+ 'exposeservice',
126
+ 'port_forward',
127
+ 'portforward',
128
+ 'dnat',
129
+ ];
130
+
131
+ describe("the console's deployable body opens no path in", () => {
132
+ test('the walk reaches the terraform and ansible it is checking', () => {
133
+ // Without this the assertion below is a confident statement about an empty
134
+ // set as soon as either directory is renamed — and the same is true on a
135
+ // fresh checkout, where the excluded build output does not exist at all.
136
+ const paths = deployableBody().map((file) => file.path);
137
+ expect(paths.some((path) => path.endsWith('terraform/main.tf.tpl'))).toBe(true);
138
+ expect(paths.some((path) => path.includes('/ansible/roles/'))).toBe(true);
139
+ expect(paths.some((path) => path.endsWith('.service.j2'))).toBe(true);
140
+ });
141
+
142
+ test('reads no build output, so a local build cannot change the verdict', () => {
143
+ const paths = deployableBody().map((file) => file.path);
144
+ expect(paths.filter((path) => path.includes(`/${BUILD_OUTPUT_DIR}/`))).toEqual([]);
145
+ });
146
+
147
+ test('names no reverse proxy, port forward or ingress address', () => {
148
+ const offenders = deployableBody()
149
+ .filter((file) => INGRESS_TOKENS.some((token) => file.text.toLowerCase().includes(token)))
150
+ .map((file) => file.path.slice(MODULE_DIR.length));
151
+ expect(offenders).toEqual([]);
152
+ });
153
+ });
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
8
  import type { DbClient } from '../db/client';
9
9
  import { NETWORK_ZONES } from '../db/schema';
10
10
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
11
- import { consoleStatus } from './projection';
11
+ import { consoleStatus, loadClosureInputs } from './projection';
12
12
 
13
13
  /** A manifest big enough to notice if it ever leaked into the payload. */
14
14
  const FAT_MANIFEST = JSON.stringify({
@@ -229,3 +229,65 @@ describe('consoleStatus derived facts', () => {
229
229
  expect(moduleOf('forgejo').backupStale).toBe(true);
230
230
  });
231
231
  });
232
+
233
+ /**
234
+ * The reach probe for the chain (CLAUDE.md, "a check that cannot reach the
235
+ * thing it is checking").
236
+ *
237
+ * `orderFirewallChain` is unit-tested against hand-built rows. That says
238
+ * nothing about whether the closure's loader queries the right table with the
239
+ * right filter, and a wrong query here returns a well-formed empty list that
240
+ * reads exactly like a fleet with one firewall. So this drives the REAL loader
241
+ * against real `capabilities` rows.
242
+ */
243
+ describe('loadClosureInputs, and the chain it carries', () => {
244
+ let db: DbClient;
245
+
246
+ beforeEach(async () => {
247
+ db = await setupTestDatabase();
248
+ for (const id of ['iptables', 'axon', 'caddy']) {
249
+ db.$client.run(
250
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
251
+ VALUES ('${id}', '${id}', '1.0.0', '/path', '{}', 'VERIFIED')`,
252
+ );
253
+ }
254
+ });
255
+
256
+ afterEach(async () => {
257
+ await cleanupTestDatabase(db);
258
+ });
259
+
260
+ function firewallProvider(moduleId: string, data: string) {
261
+ db.$client.run(
262
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
263
+ VALUES ('${moduleId}', 'firewall', '1.0.0', '${data}')`,
264
+ );
265
+ }
266
+
267
+ test('reads the live registration and orders it, iptables first', () => {
268
+ firewallProvider('iptables', '{"firewall_ip":"192.168.0.254","nat_ip":"192.168.0.253"}');
269
+ firewallProvider('axon', '{"has_external":true,"router_ip":"192.168.0.1"}');
270
+
271
+ expect(loadClosureInputs(db).chains).toEqual([
272
+ { capability: 'firewall', moduleIds: ['iptables', 'axon'] },
273
+ ]);
274
+ });
275
+
276
+ test('a non-firewall capability is not swept into the chain', () => {
277
+ // `axon` provides dhcp_server too. A loader that read every row would put a
278
+ // module in the packet's path on the strength of an unrelated capability.
279
+ firewallProvider('iptables', '{"firewall_ip":"192.168.0.254","nat_ip":"192.168.0.253"}');
280
+ firewallProvider('axon', '{"has_external":true}');
281
+ db.$client.run(
282
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
283
+ VALUES ('caddy', 'public_web', '1.0.0', '{}')`,
284
+ );
285
+
286
+ expect(loadClosureInputs(db).chains[0]?.moduleIds).toEqual(['iptables', 'axon']);
287
+ });
288
+
289
+ test('a fleet with one firewall has no chain', () => {
290
+ firewallProvider('axon', '{"has_external":true}');
291
+ expect(loadClosureInputs(db).chains).toEqual([]);
292
+ });
293
+ });
@@ -14,16 +14,25 @@
14
14
  * succeed quietly.
15
15
  */
16
16
 
17
+ import { FIREWALL_CAPABILITY_NAME, orderFirewallChain } from '@celilo/capabilities';
17
18
  import { desc, eq } from 'drizzle-orm';
18
19
  import { z } from 'zod';
19
20
  import type { DbClient } from '../db/client';
20
- import { NETWORK_ZONES, backups, moduleSystems, modules, monitors } from '../db/schema';
21
+ import {
22
+ NETWORK_ZONES,
23
+ backups,
24
+ capabilities,
25
+ moduleSystems,
26
+ modules,
27
+ monitors,
28
+ } from '../db/schema';
21
29
  import { loadObservedHealthDetail } from '../services/alerting/observed-health';
22
30
  import { loadBackupAuditInfo } from '../services/audit/backup-source';
23
31
  import { backupStaleThresholdMs, moduleHasBackupHook } from '../services/audit/backups';
24
32
  import { effectiveBackupSchedule } from '../services/backup-schedule';
25
33
  import { listCapabilityBindings } from '../services/capability-bindings';
26
34
  import type { ConsumedCapabilities } from '../services/consumer-cleanup';
35
+ import type { DelegationChain } from './closure';
27
36
 
28
37
  /**
29
38
  * Just the capability lists, validated at the boundary.
@@ -302,10 +311,18 @@ export function loadBindings(db: DbClient, moduleId: string): Map<string, string
302
311
  );
303
312
  }
304
313
 
305
- /** Everything the closure walk needs, read in one place. */
314
+ /**
315
+ * Everything the closure walk needs, read in ONE place.
316
+ *
317
+ * The chains are in here rather than beside it deliberately. A separate loader
318
+ * a caller has to remember is a caller that forgets, and the answer when it
319
+ * forgets is an empty chain list that reads exactly like a fleet with no
320
+ * delegation. There is one call site, and it cannot get a partial set.
321
+ */
306
322
  export function loadClosureInputs(db: DbClient): {
307
323
  manifests: Map<string, ConsumedCapabilities>;
308
324
  providerStates: { moduleId: string; state: string }[];
325
+ chains: DelegationChain[];
309
326
  } {
310
327
  return {
311
328
  manifests: new Map(
@@ -316,9 +333,29 @@ export function loadClosureInputs(db: DbClient): {
316
333
  .map((row) => [row.id, CONSUMED_CAPABILITIES.parse(row.manifestData)] as const),
317
334
  ),
318
335
  providerStates: db.select({ moduleId: modules.id, state: modules.state }).from(modules).all(),
336
+ chains: loadFirewallChain(db),
319
337
  };
320
338
  }
321
339
 
340
+ /**
341
+ * The `firewall` providers in delegation order, as at most one chain.
342
+ *
343
+ * At most one because `firewall` is the only capability celilo chains, and
344
+ * `orderFirewallChain` — the SAME function the hook loader wires the live
345
+ * providers with — is what decides the order. A fleet with one firewall, or
346
+ * with none that reaches the internet, yields no chain at all.
347
+ */
348
+ export function loadFirewallChain(db: DbClient): DelegationChain[] {
349
+ const providers = db
350
+ .select({ moduleId: capabilities.moduleId, data: capabilities.data })
351
+ .from(capabilities)
352
+ .where(eq(capabilities.capabilityName, FIREWALL_CAPABILITY_NAME))
353
+ .all();
354
+
355
+ const ordered = orderFirewallChain(providers).map((provider) => provider.moduleId);
356
+ return ordered.length === 0 ? [] : [{ capability: FIREWALL_CAPABILITY_NAME, moduleIds: ordered }];
357
+ }
358
+
322
359
  /** A module's row, or undefined. Used to reject a closure request for a module that is gone. */
323
360
  export function moduleExists(db: DbClient, moduleId: string): boolean {
324
361
  return (
package/src/db/schema.ts CHANGED
@@ -447,7 +447,6 @@ export const moduleBuilds = sqliteTable('module_builds', {
447
447
  version: text('version').notNull(),
448
448
  builtAt: integer('built_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
449
449
  artifacts: text('artifacts', { mode: 'json' }).$type<string[]>().notNull(), // Array of artifact paths
450
- environment: text('environment').$type<'nix' | 'system'>(),
451
450
  status: text('status').$type<BuildStatus>().notNull(),
452
451
  buildLog: text('build_log'), // Build output for debugging
453
452
  });