@celilo/cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -119,6 +119,7 @@ import { handleSubscribersTest } from './commands/subscribers-test';
119
119
  import { handleSystemApplyConfig } from './commands/system-apply-config';
120
120
  import { handleSystemAudit } from './commands/system-audit';
121
121
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
122
+ import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
122
123
  import { handleSystemDoctor } from './commands/system-doctor';
123
124
  import { handleSystemInit } from './commands/system-init';
124
125
  import { handleSystemMigrate } from './commands/system-migrate';
@@ -1100,6 +1101,10 @@ Subcommands:
1100
1101
  apply-config <key=val>... | --from-stdin
1101
1102
  Headless config write — designed for module
1102
1103
  hooks (no prompts, no guidance, just writes).
1104
+ Refuses network.* keys: networks are celilo's
1105
+ to define, not a module's.
1106
+
1107
+ discover-network Record the network this box sits on, from its own routing table (idempotent)
1103
1108
 
1104
1109
  config set <key> <value> Set system-wide configuration value
1105
1110
  config get [key] Get system configuration value(s)
@@ -2173,6 +2178,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2173
2178
  return handleSystemApplyConfig(parsed.args, parsed.flags);
2174
2179
  }
2175
2180
 
2181
+ if (parsed.subcommand === 'discover-network') {
2182
+ return handleSystemDiscoverNetwork();
2183
+ }
2184
+
2176
2185
  if (parsed.subcommand === 'config') {
2177
2186
  const configSubcommand = parsed.args[0];
2178
2187
  if (!configSubcommand) {
@@ -632,8 +632,8 @@ function buildCapabilityInterface(
632
632
  undefined, // no upstream — the chain path handles that
633
633
  undefined, // logger is applied by wrapWithLogging at the loader site
634
634
  trustedSourceStore,
635
- // LIVE, for the same reason as the chain path: a declaration written
636
- // during this hook run must be visible to the converge that follows it.
635
+ // LIVE see FirewallZones.declaredNetworks for why this stays live
636
+ // even though nothing writes a network mid-run any more.
637
637
  zones ? { list: zones.declaredNetworks } : undefined,
638
638
  );
639
639
  return stampProvider(iface, providerModuleId);
@@ -742,10 +742,28 @@ interface FirewallZones {
742
742
  * subnet (design D1, amended).
743
743
  */
744
744
  /**
745
- * A LIVE read, not a snapshot. Everything else here is captured when the
746
- * capability is built before any hook runs — and for declarations that is
747
- * wrong: `wireguard` declares its VPN subnet and brings `wg0` up inside one
748
- * hook run, and the converge that follows has to see the declaration.
745
+ * A LIVE read, not a snapshot and it STAYS live, deliberately, now that the
746
+ * race it was introduced for is gone.
747
+ *
748
+ * It was introduced because `wireguard` declared its VPN subnet and brought
749
+ * `wg0` up inside one hook run, so a converge later in that run had to see a
750
+ * value written after the capability was built. Modules no longer write
751
+ * networks at all (openspec/changes/networks-are-declared-not-written), and
752
+ * `ensureRequiredNetworks` defines every required one BEFORE the deploy loads
753
+ * any capability. So a snapshot would be correct today.
754
+ *
755
+ * It is kept live because being correct today is not the property worth having
756
+ * here. What a stale read costs is specific and severe: an interface celilo
757
+ * cannot attribute is ALIEN, and on a firewall with a recorded baseline an
758
+ * alien interface is disabled — which is how a converge came to shut down the
759
+ * admin VPN, the operator's way back into a remote box. A live read costs one
760
+ * query per converge. That is a trade worth making permanently, not one to
761
+ * re-evaluate whenever the write paths happen to look clean.
762
+ *
763
+ * And they are not entirely clean: `celilo system discover-network` writes
764
+ * `network.internal.*` from inside `celilo-mgmt`'s install hook. No firewall
765
+ * capability exists during that deploy, so it cannot bite — today. Liveness is
766
+ * what makes that sentence not need to stay true.
749
767
  */
750
768
  declaredNetworks: () => Array<{ zone: string; subnet: string }>;
751
769
  /**
@@ -851,14 +869,13 @@ export function loadTrustedSubnets(db: DbClient, firewallIp?: string): TrustedSu
851
869
  * Every network celilo can attribute an interface to: one entry per
852
870
  * `network.<name>.subnet` in system config.
853
871
  *
854
- * Read from the config rather than from `NETWORK_ZONES`, because celilo declares
855
- * networks that are not placement zones. `wireguard`'s `on_install` writes
856
- * `network.control-plane-vpn.subnet` before it brings `wg0` up, exactly as the
857
- * design requires (D3), and the firewall is supposed to classify `wg0` "by the
858
- * same subnet containment it uses for every other leg". Reading only
859
- * `NETWORK_ZONES` left that declaration invisible: the converge could not
860
- * attribute `wg0` and refused and on a firewall with a baseline it would have
861
- * ISOLATED it, shutting down the admin VPN.
872
+ * Read from the config rather than from `NETWORK_ZONES`, because celilo holds
873
+ * networks that are not placement zones `network.control-plane-vpn.subnet`,
874
+ * which `wireguard` requires and reads. The firewall classifies `wg0` "by the
875
+ * same subnet containment it uses for every other leg", and reading only
876
+ * `NETWORK_ZONES` left that network invisible: the converge could not attribute
877
+ * `wg0` and refused and on a firewall with a baseline it would have ISOLATED
878
+ * it, shutting down the admin VPN.
862
879
  *
863
880
  * `external` is deliberately absent even when something has set
864
881
  * `network.external.subnet`: it is the RESIDUAL, decided by `isPubliclyRoutable`
@@ -1109,9 +1126,8 @@ async function buildFirewallChain(
1109
1126
  currentUpstream,
1110
1127
  logger,
1111
1128
  trustedSourceStore,
1112
- // LIVE, not a snapshot: a declaration written during this hook run —
1113
- // `wireguard` declaring its VPN subnet before creating `wg0` — must be
1114
- // visible to the converge that follows it in the same run.
1129
+ // LIVE see FirewallZones.declaredNetworks. Cheap insurance against a
1130
+ // failure whose consequence is isolating the admin VPN.
1115
1131
  { list: zones.declaredNetworks },
1116
1132
  );
1117
1133
 
@@ -17,8 +17,9 @@
17
17
  */
18
18
 
19
19
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { eq } from 'drizzle-orm';
20
21
  import type { DbClient } from '../db/client';
21
- import { machines, moduleInfrastructure, modules } from '../db/schema';
22
+ import { machines, moduleConfigs, moduleInfrastructure, modules, systemConfig } from '../db/schema';
22
23
  import { upsertModuleConfig } from '../services/module-config';
23
24
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
24
25
  import { loadHookConfigMap } from './load-hook-config';
@@ -165,3 +166,170 @@ describe('loadHookConfigMap', () => {
165
166
  expect(result['ip.primary']).toBeUndefined();
166
167
  });
167
168
  });
169
+
170
+ /**
171
+ * The 2026-08-14 outage, as a test.
172
+ *
173
+ * `technitium` declares `vpn_subnet` with `source: system` and
174
+ * `derive_from: $system:network.control-plane-vpn.subnet`. The system key was
175
+ * set on the fleet AFTER the module had already been configured, so no
176
+ * `module_configs` row was ever written for it — `applyDeclarativeDerivations`
177
+ * skips a `source: system` variable once the module has any stored config for
178
+ * it, and nothing re-derives one that is absent. The variable is
179
+ * `required: false`, so deploy-validation passed without comment.
180
+ *
181
+ * The consumer was a capability factory, which gets its config from
182
+ * `loadHookConfigMap`. A direct `module_configs` select cannot see a value that
183
+ * was never stored, so the factory built split-horizon DNS with no view for the
184
+ * admin VPN: queries from it matched nothing, returned NOERROR with zero
185
+ * records, fell through to public DNS, and the operator could not reach the
186
+ * forge while every service reported healthy.
187
+ *
188
+ * The fix is that the hook config map is built from the resolution context —
189
+ * which recomputes derived values on every build — not from the table alone.
190
+ */
191
+ describe('loadHookConfigMap: derived values the config table never stored', () => {
192
+ let db: DbClient;
193
+
194
+ const VPN_SUBNET_KEY = 'network.control-plane-vpn.subnet';
195
+ const VPN_SUBNET = '10.255.255.0/24';
196
+
197
+ beforeEach(async () => {
198
+ db = await setupTestDatabase();
199
+ db.insert(modules)
200
+ .values({
201
+ id: 'technitium',
202
+ name: 'Technitium DNS',
203
+ version: '1.0.0',
204
+ sourcePath: '/tmp/technitium',
205
+ manifestData: {
206
+ variables: {
207
+ owns: [
208
+ { name: 'hostname', type: 'string', source: 'user' },
209
+ {
210
+ name: 'vpn_subnet',
211
+ type: 'string',
212
+ source: 'system',
213
+ required: false,
214
+ derive_from: `$system:${VPN_SUBNET_KEY}`,
215
+ },
216
+ ],
217
+ },
218
+ },
219
+ })
220
+ .run();
221
+ // The module was configured first: it has its user rows, and no row for
222
+ // the derived variable.
223
+ upsertModuleConfig(db, 'technitium', 'hostname', 'dns-int');
224
+ });
225
+
226
+ afterEach(async () => {
227
+ await cleanupTestDatabase(db);
228
+ });
229
+
230
+ test('a hook sees a source:system derive whose system key was set after the module was configured', async () => {
231
+ db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
232
+
233
+ const result = await loadHookConfigMap('technitium', db);
234
+
235
+ expect(result.hostname).toBe('dns-int');
236
+ expect(result.vpn_subnet).toBe(VPN_SUBNET);
237
+ });
238
+
239
+ test('recomputing does not write the derived value back into module_configs', async () => {
240
+ db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
241
+
242
+ await loadHookConfigMap('technitium', db);
243
+
244
+ // Reading a hook's config is not a deploy. It must not seed or refresh
245
+ // stored config as a side effect — that is how a stale snapshot gets
246
+ // written in the first place.
247
+ const stored = db
248
+ .select()
249
+ .from(moduleConfigs)
250
+ .where(eq(moduleConfigs.moduleId, 'technitium'))
251
+ .all();
252
+ expect(stored.map((row) => row.key).sort()).toEqual(['hostname']);
253
+ });
254
+
255
+ test('an optional derive whose system key is still unset stays absent, not an error', async () => {
256
+ // No systemConfig row at all — the state the fleet was in before the VPN
257
+ // subnet was declared. `required: false`, so this is silence, not failure.
258
+ const result = await loadHookConfigMap('technitium', db);
259
+
260
+ expect(result.hostname).toBe('dns-int');
261
+ expect(result.vpn_subnet).toBeUndefined();
262
+ });
263
+
264
+ test('an already-stored value reaches the hook unchanged', async () => {
265
+ // Precedence: a stored row wins, and the recomputed context only fills
266
+ // keys the table has no row for. Every derived value on the fleet today is
267
+ // stored, so this is what keeps the new read path from changing what any
268
+ // deployed hook already receives — the change is purely additive here.
269
+ // (Once derived values stop being persisted, the stored row disappears and
270
+ // the recomputed value is all that is left.)
271
+ db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
272
+ upsertModuleConfig(db, 'technitium', 'vpn_subnet', '10.99.0.0/24');
273
+
274
+ const result = await loadHookConfigMap('technitium', db);
275
+
276
+ expect(result.vpn_subnet).toBe('10.99.0.0/24');
277
+ });
278
+ });
279
+
280
+ /**
281
+ * Recomputation must not make a hook's config LESS available than reading the
282
+ * table did.
283
+ *
284
+ * `applyDeclarativeDerivations` throws when a `required: true` variable's
285
+ * derivation fails, which at generate time is exactly right — a deploy that
286
+ * cannot resolve a required value should stop. But this reader also serves
287
+ * health checks and `module run-hook`, which used to be unable to fail this
288
+ * way at all: they read stored rows, and a stored row cannot throw. A provider
289
+ * that is paused, removed, or not yet deployed would take its consumers'
290
+ * health checks down with it — reporting the consumer as broken when the
291
+ * consumer is fine.
292
+ */
293
+ describe('loadHookConfigMap: a failing derive does not take the hook down', () => {
294
+ let db: DbClient;
295
+
296
+ beforeEach(async () => {
297
+ db = await setupTestDatabase();
298
+ db.insert(modules)
299
+ .values({
300
+ id: 'caddy',
301
+ name: 'Caddy',
302
+ version: '1.0.0',
303
+ sourcePath: '/tmp/caddy',
304
+ manifestData: {
305
+ variables: {
306
+ owns: [
307
+ { name: 'hostname', type: 'string', source: 'user' },
308
+ {
309
+ name: 'primary_domain',
310
+ type: 'string',
311
+ source: 'capability',
312
+ required: true,
313
+ derive_from: '$capability:dns_registrar.zone.primary_domain',
314
+ },
315
+ ],
316
+ },
317
+ },
318
+ })
319
+ .run();
320
+ upsertModuleConfig(db, 'caddy', 'hostname', 'caddy');
321
+ });
322
+
323
+ afterEach(async () => {
324
+ await cleanupTestDatabase(db);
325
+ });
326
+
327
+ test('stored config still reaches the hook when a required derive cannot resolve', async () => {
328
+ // No `dns_registrar` capability is registered — the provider is paused,
329
+ // removed, or has not been deployed yet.
330
+ const result = await loadHookConfigMap('caddy', db);
331
+
332
+ expect(result.hostname).toBe('caddy');
333
+ expect(result.primary_domain).toBeUndefined();
334
+ });
335
+ });
@@ -11,30 +11,65 @@
11
11
  * `capability-loader.ts:loadModuleConfig` had — same shape, different
12
12
  * code, easy to miss.
13
13
  *
14
- * The shape:
15
- * - Every row from `module_configs`, parsed from `valueJson` via
16
- * the shared `parseStoredConfigValue` helper. This preserves the
17
- * types declared in each module's manifest: `number` reads as
18
- * `number`, `boolean` as `boolean`, complex types as their
19
- * parsed JSON shape. Pre-Defect-1, this path returned raw strings
20
- * for primitives (because `valueJson` was NULL for them); that
21
- * broke capability calls like `firewall.exposeService({ports:[...]})`
22
- * that did a `typeof === 'number'` check downstream. Fixed in
23
- * v2 by always populating valueJson on write.
24
- * - If `target_ip` isn't in the row set, look up the deployment
25
- * machine and inject both `target_ip` AND `ip.primary` from
26
- * `machines.ipAddress`. Two keys because consumers historically used
27
- * either name (e.g. caddy's `setup-network.ts` checks
28
- * `target_ip || ip.primary`); fixing the drift means filling both.
14
+ * The shape, in the order the layers are applied:
15
+ * 1. Every row from `module_configs`, parsed from `valueJson` via
16
+ * the shared `parseStoredConfigValue` helper. This preserves the
17
+ * types declared in each module's manifest: `number` reads as
18
+ * `number`, `boolean` as `boolean`, complex types as their
19
+ * parsed JSON shape. Pre-Defect-1, this path returned raw strings
20
+ * for primitives (because `valueJson` was NULL for them); that
21
+ * broke capability calls like `firewall.exposeService({ports:[...]})`
22
+ * that did a `typeof === 'number'` check downstream. Fixed in
23
+ * v2 by always populating valueJson on write.
24
+ * 2. If `target_ip` isn't in the row set, look up the deployment
25
+ * machine and inject both `target_ip` AND `ip.primary` from
26
+ * `machines.ipAddress`. Two keys because consumers historically used
27
+ * either name (e.g. caddy's `setup-network.ts` checks
28
+ * `target_ip || ip.primary`); fixing the drift means filling both.
29
+ * 3. For each variable the module's manifest declares, the value the
30
+ * resolution context currently computes — but only where the first two
31
+ * layers left that key unset. See below.
29
32
  *
30
33
  * Container deploys write `target_ip` into `module_configs` explicitly
31
34
  * during generate/deploy, so the fallback only fires for machine
32
35
  * deploys (existing iron, no terraform — what every e2e test uses).
36
+ *
37
+ * ## Why layer 3 exists
38
+ *
39
+ * A derived variable — one whose value comes from system config, a
40
+ * capability, or the selected infrastructure rather than from the operator —
41
+ * only reaches a hook through this map. Reading `module_configs` alone can
42
+ * only see the derives that happen to have been WRITTEN there, and one that
43
+ * was never written is indistinguishable from one that does not exist.
44
+ *
45
+ * On 2026-08-14 that was not a theoretical gap. `technitium`'s `vpn_subnet`
46
+ * (`source: system`) had no row, because the system key was set after the
47
+ * module was configured and nothing re-derives a `source: system` value that
48
+ * is already absent. Being `required: false`, deploy-validation passed in
49
+ * silence. The capability factory that builds split-horizon DNS therefore
50
+ * built no view for the admin VPN; queries from it matched nothing, returned
51
+ * NOERROR with zero records, fell through to public DNS, and the operator
52
+ * could not reach the forge while every service reported healthy.
53
+ *
54
+ * So the map is completed from `readResolutionContext` — the same computation
55
+ * a build does, run without any of a build's side effects. Stored rows still
56
+ * win, which is what makes this purely additive for every value already on
57
+ * the fleet; the context only supplies what the table is missing. Context
58
+ * values arrive as strings (declarative derivation resolves string templates
59
+ * only), so this layer never overwrites a typed row with a stringified one.
60
+ *
61
+ * It is narrowed to the manifest's declared variables on purpose. The
62
+ * resolution context also carries values that exist to drive template
63
+ * generation — `inventory.*`, `requires.system.*`, `lxc_nameserver` — which a
64
+ * hook has no business reading and which have never appeared in this map.
65
+ * Widening a hook's view of the world is not what this layer is for.
33
66
  */
34
67
  import { eq } from 'drizzle-orm';
35
68
  import type { DbClient } from '../db/client';
36
- import { machines, moduleConfigs, moduleInfrastructure } from '../db/schema';
69
+ import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema';
70
+ import type { ModuleManifest } from '../manifest/schema';
37
71
  import { parseStoredConfigValue } from '../services/module-config';
72
+ import { readResolutionContext } from '../variables/context';
38
73
 
39
74
  export async function loadHookConfigMap(
40
75
  moduleId: string,
@@ -51,19 +86,82 @@ export async function loadHookConfigMap(
51
86
  configMap[c.key] = parseStoredConfigValue(c);
52
87
  }
53
88
 
54
- if (configMap.target_ip) return configMap;
89
+ applyMachineAddressFallback(configMap, moduleId, db);
90
+ await applyRecomputedDerivedValues(configMap, moduleId, db);
91
+
92
+ return configMap;
93
+ }
94
+
95
+ /**
96
+ * Fill in the declared variables the config rows have no value for, using the
97
+ * value the resolution context computes right now. Mutates `configMap`.
98
+ */
99
+ async function applyRecomputedDerivedValues(
100
+ configMap: Record<string, unknown>,
101
+ moduleId: string,
102
+ db: DbClient,
103
+ ): Promise<void> {
104
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
105
+ if (!module?.manifestData) return;
106
+
107
+ const declared = (module.manifestData as ModuleManifest).variables?.owns ?? [];
108
+ const missing = declared.filter((variable) => !(variable.name in configMap));
109
+ if (missing.length === 0) return;
110
+
111
+ let context: Awaited<ReturnType<typeof readResolutionContext>>;
112
+ try {
113
+ context = await readResolutionContext(moduleId, db);
114
+ } catch (error) {
115
+ // Derivation THROWS when a `required: true` variable cannot be resolved —
116
+ // correct at generate time, where a deploy that cannot resolve a required
117
+ // value should stop. This reader also serves health checks and
118
+ // `module run-hook`, which previously could not fail this way at all: a
119
+ // stored row cannot throw. Letting it propagate would mean a provider that
120
+ // is paused, removed, or not yet deployed takes down the health checks of
121
+ // every module that derives from it, reporting healthy consumers as
122
+ // broken.
123
+ //
124
+ // So the hook falls back to what the table holds — exactly what it
125
+ // received before recomputation existed, never less. Logged rather than
126
+ // swallowed (Rule 6.2): a derive that cannot resolve is worth knowing
127
+ // about even when the hook survives it.
128
+ console.error(
129
+ `Could not recompute derived config for '${moduleId}'; the hook sees only its stored config. ` +
130
+ `Derived values (${missing.map((v) => v.name).join(', ')}) may be missing:`,
131
+ error,
132
+ );
133
+ return;
134
+ }
135
+
136
+ for (const variable of missing) {
137
+ const value = context.selfConfig[variable.name];
138
+ if (value !== undefined) {
139
+ configMap[variable.name] = value;
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Fill `target_ip` / `ip.primary` from the deployment machine when the config
146
+ * rows carry no address of their own. Mutates `configMap` in place.
147
+ */
148
+ function applyMachineAddressFallback(
149
+ configMap: Record<string, unknown>,
150
+ moduleId: string,
151
+ db: DbClient,
152
+ ): void {
153
+ if (configMap.target_ip) return;
55
154
 
56
155
  const infra = db
57
156
  .select()
58
157
  .from(moduleInfrastructure)
59
158
  .where(eq(moduleInfrastructure.moduleId, moduleId))
60
159
  .get();
61
- if (!infra?.machineId) return configMap;
160
+ if (!infra?.machineId) return;
62
161
 
63
162
  const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
64
- if (!machine) return configMap;
163
+ if (!machine) return;
65
164
 
66
165
  configMap.target_ip = machine.ipAddress;
67
166
  configMap['ip.primary'] = machine.ipAddress;
68
- return configMap;
69
167
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `requires.networks` — a module NAMES the networks it depends on and never
3
+ * carries their values
4
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
5
+ *
6
+ * The rejection below is the schema-level half of "a module has no path to
7
+ * create a network definition". The other half is the write path
8
+ * ([[cli/commands/system-apply-config.ts]]).
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { NetworkRequirementSchema, getRequiredNetworkNames } from './schema';
13
+ import type { ModuleManifest } from './schema';
14
+
15
+ describe('NetworkRequirementSchema', () => {
16
+ test('a requirement is just a name', () => {
17
+ expect(NetworkRequirementSchema.safeParse({ name: 'control-plane-vpn' }).success).toBe(true);
18
+ });
19
+
20
+ test('names are kebab-case, like every other user-facing identifier', () => {
21
+ expect(NetworkRequirementSchema.safeParse({ name: 'control_plane_vpn' }).success).toBe(false);
22
+ expect(NetworkRequirementSchema.safeParse({ name: 'DMZ' }).success).toBe(false);
23
+ });
24
+
25
+ test('a requirement carrying a subnet is rejected, and the message says why', () => {
26
+ const result = NetworkRequirementSchema.safeParse({
27
+ name: 'control-plane-vpn',
28
+ subnet: '10.255.255.0/24',
29
+ });
30
+
31
+ expect(result.success).toBe(false);
32
+ if (result.success) return;
33
+ const message = result.error.errors.map((e) => e.message).join(' ');
34
+ expect(message).toContain('celilo owns');
35
+ expect(message).toContain('$system:network.<name>.subnet');
36
+ });
37
+
38
+ test('a gateway or vlan on the requirement is rejected too', () => {
39
+ expect(NetworkRequirementSchema.safeParse({ name: 'dmz', gateway: '10.0.10.1' }).success).toBe(
40
+ false,
41
+ );
42
+ expect(NetworkRequirementSchema.safeParse({ name: 'dmz', vlan: 10 }).success).toBe(false);
43
+ });
44
+
45
+ /**
46
+ * `from:` is the second form, for a module whose required set is decided per
47
+ * install rather than at authoring time — a firewall requires the networks it
48
+ * has legs on, and which legs it has is a property of the box it lands on.
49
+ * It still names networks and still carries no values.
50
+ */
51
+ test('a requirement may name a config array instead of a literal', () => {
52
+ expect(NetworkRequirementSchema.safeParse({ from: '$self:zones' }).success).toBe(true);
53
+ });
54
+
55
+ test("from must reference this module's own config, not another source", () => {
56
+ // `$machine:` / `$capability:` would make some other thing the author of the
57
+ // required set, which is the authority question this change exists to settle.
58
+ expect(NetworkRequirementSchema.safeParse({ from: '$machine:zones' }).success).toBe(false);
59
+ expect(NetworkRequirementSchema.safeParse({ from: '$capability:firewall.zones' }).success).toBe(
60
+ false,
61
+ );
62
+ expect(NetworkRequirementSchema.safeParse({ from: 'zones' }).success).toBe(false);
63
+ });
64
+
65
+ test('exactly one of name or from — neither both nor neither', () => {
66
+ expect(NetworkRequirementSchema.safeParse({ name: 'dmz', from: '$self:zones' }).success).toBe(
67
+ false,
68
+ );
69
+ expect(NetworkRequirementSchema.safeParse({}).success).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe('getRequiredNetworkNames', () => {
74
+ function manifestWith(
75
+ networks: Array<{ name?: string; from?: string }> | undefined,
76
+ ): ModuleManifest {
77
+ return {
78
+ requires: { capabilities: [], networks },
79
+ } as unknown as ModuleManifest;
80
+ }
81
+
82
+ test('is empty for a module that requires no network', () => {
83
+ expect(getRequiredNetworkNames(manifestWith(undefined))).toEqual([]);
84
+ });
85
+
86
+ test('keeps declaration order and drops duplicates', () => {
87
+ expect(
88
+ getRequiredNetworkNames(manifestWith([{ name: 'dmz' }, { name: 'app' }, { name: 'dmz' }])),
89
+ ).toEqual(['dmz', 'app']);
90
+ });
91
+
92
+ test("resolves a from: requirement against the module's own config", () => {
93
+ expect(
94
+ getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
95
+ zones: ['dmz', 'app', 'secure'],
96
+ }),
97
+ ).toEqual(['dmz', 'app', 'secure']);
98
+ });
99
+
100
+ test('an array stored as JSON resolves too', () => {
101
+ // Array config arrives as either shape depending on how it was written, and
102
+ // a requirement that silently resolved to nothing would leave a firewall leg
103
+ // undeclared — which is how an interface ends up classified alien.
104
+ expect(
105
+ getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
106
+ zones: '["dmz","secure-mgmt"]',
107
+ }),
108
+ ).toEqual(['dmz', 'secure-mgmt']);
109
+ });
110
+
111
+ /**
112
+ * `external` is the RESIDUAL: an interface is external because it is publicly
113
+ * routable and matched no declared zone, never because it is contained in a
114
+ * subnet. Giving it one would reintroduce the overload that
115
+ * `readDeclaredNetworks` and `deployFirewall` both already exclude.
116
+ */
117
+ test('external is dropped, however it is named', () => {
118
+ expect(
119
+ getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
120
+ zones: ['dmz', 'external'],
121
+ }),
122
+ ).toEqual(['dmz']);
123
+ expect(getRequiredNetworkNames(manifestWith([{ name: 'external' }]))).toEqual([]);
124
+ });
125
+
126
+ test('an unanswered config array requires nothing rather than throwing', () => {
127
+ // The firewall's zones are answered by the config interview, which runs
128
+ // before this. An empty result here means the interview has not happened
129
+ // yet, not that the module needs no networks.
130
+ expect(getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {})).toEqual([]);
131
+ });
132
+
133
+ test('literal and dynamic requirements combine', () => {
134
+ expect(
135
+ getRequiredNetworkNames(
136
+ manifestWith([{ name: 'control-plane-vpn' }, { from: '$self:zones' }]),
137
+ { zones: ['dmz'] },
138
+ ),
139
+ ).toEqual(['control-plane-vpn', 'dmz']);
140
+ });
141
+ });