@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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Ensure every network a module REQUIRES is defined before the deploy proceeds
3
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
4
+ *
5
+ * celilo owns the network namespace. A module names the networks it depends on
6
+ * under `requires.networks`; it never carries their values. When one of those
7
+ * networks has no `network.<name>.subnet` in system config, this asks for the
8
+ * range over the event bus and writes it — celilo asking, celilo writing.
9
+ *
10
+ * Two properties matter and both come from WHERE this runs rather than from
11
+ * anything clever it does:
12
+ *
13
+ * - It runs in the deploy's interview phase, before generation and before any
14
+ * hook. So by the time a hook executes, the network is defined — whether the
15
+ * consumer reading declared networks captured them eagerly or reads at point
16
+ * of use. That is what retires the live-reader mitigation the firewall
17
+ * capability carries today (celilo#759).
18
+ * - It asks through the generic bus interview, so it is answerable by whatever
19
+ * responder is attached — a terminal, `celilo events reply`, an automated
20
+ * policy — and behaves identically interactive or headless.
21
+ *
22
+ * ── What is asked, and what is merely observed ──
23
+ *
24
+ * Not every attribute of a network is a question. The rule is whether celilo can
25
+ * already SEE the answer:
26
+ *
27
+ * - `subnet` is ASKED. It is an addressing-plan decision that predates every
28
+ * module, and nothing in the fleet can be consulted for it. A well-known name
29
+ * is offered a suggested range so an operator new to networking is not made to
30
+ * invent one — an offer in a question, never a seeded row.
31
+ * - `gateway` is OBSERVED, never asked. It is the address a router answers on
32
+ * inside that subnet, which celilo already holds: `machine add` catalogues
33
+ * every interface of every machine. Asking for it would be asking the operator
34
+ * to retype something celilo can look up, which is the same failure this change
35
+ * exists to remove, aimed at a different key.
36
+ * - `vlan` is ASKED, optional, blank meaning untagged. It is NOT observable —
37
+ * a catalogued interface carries `{name, ipAddress, zone}` and no tag — and it
38
+ * IS load-bearing: every container-provisioning template reads
39
+ * `$system:network.<zone>.vlan` as the Proxmox NIC tag. Leaving it uncollected
40
+ * would provision containers untagged onto the wrong VLAN, silently.
41
+ *
42
+ * Which attributes a network HAS at all is celilo's answer too, taken from
43
+ * `schemas/system_config.json`: it declares `gateway`/`vlan` for the routed
44
+ * segments and omits both for the control-plane VPN, which has neither. So a
45
+ * module never states which attributes it reads, and asking a nonsense question
46
+ * is impossible by construction.
47
+ */
48
+
49
+ import { subnetContains } from '@celilo/capabilities';
50
+ import { eq } from 'drizzle-orm';
51
+ import type { DbClient } from '../db/client';
52
+ import { machines, moduleConfigs, systemConfig } from '../db/schema';
53
+ import { type ModuleManifest, getRequiredNetworkNames } from '../manifest/schema';
54
+ import { askText } from './bus-interview';
55
+ import { loadSchema } from './system-init';
56
+
57
+ export interface NetworkEnsureResult {
58
+ success: boolean;
59
+ error?: string;
60
+ /** What this call defined, as `<key> = <value>` lines for the deploy log. */
61
+ applied: string[];
62
+ }
63
+
64
+ /** The one piece of I/O here, injectable so the rules are testable without a bus. */
65
+ export type NetworkAsker = (question: {
66
+ scope: string;
67
+ key: string;
68
+ message: string;
69
+ description: string;
70
+ defaultValue?: string;
71
+ placeholder?: string;
72
+ required: boolean;
73
+ pattern?: string;
74
+ }) => Promise<string>;
75
+
76
+ /**
77
+ * The module's own config, flat, for resolving a `from:` requirement.
78
+ *
79
+ * Reads `value_json` in preference to `value`: an array config value is stored
80
+ * as JSON, and reading the scalar column would yield the string form, which
81
+ * `getRequiredNetworkNames` would then have to guess at.
82
+ */
83
+ function loadModuleConfigValues(db: DbClient, moduleId: string): Record<string, unknown> {
84
+ const values: Record<string, unknown> = {};
85
+ for (const row of db
86
+ .select()
87
+ .from(moduleConfigs)
88
+ .where(eq(moduleConfigs.moduleId, moduleId))
89
+ .all()) {
90
+ values[row.key] = row.valueJson ?? row.value;
91
+ }
92
+ return values;
93
+ }
94
+
95
+ function readSystemConfigValue(db: DbClient, key: string): string | undefined {
96
+ const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
97
+ return row?.value && row.value.length > 0 ? row.value : undefined;
98
+ }
99
+
100
+ function writeSystemConfigValue(db: DbClient, key: string, value: string): void {
101
+ db.insert(systemConfig)
102
+ .values({ key, value })
103
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
104
+ .run();
105
+ }
106
+
107
+ /**
108
+ * The address a router answers on inside `subnet`, from the machine catalogue.
109
+ *
110
+ * Matched by CONTAINMENT rather than by an interface's recorded zone, because on
111
+ * a fleet that is still being described those are not the same thing: an
112
+ * interface is classified into a zone by comparing it against declared subnets,
113
+ * so before the subnet exists the interface has no zone. Containment answers the
114
+ * moment the operator supplies the range, which is exactly when this runs.
115
+ *
116
+ * `role: 'router'` is what distinguishes the gateway from any other host that
117
+ * happens to sit in the subnet.
118
+ */
119
+ export function observeGateway(db: DbClient, subnet: string): string | undefined {
120
+ for (const machine of db.select().from(machines).all()) {
121
+ if (machine.role !== 'router') continue;
122
+ for (const iface of machine.interfaces) {
123
+ if (iface.ipAddress && subnetContains(subnet, iface.ipAddress)) {
124
+ return iface.ipAddress;
125
+ }
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ /**
132
+ * Ensure each of `manifest.requires.networks` is defined: a subnet, a vlan tag
133
+ * where one applies, and a gateway wherever celilo can see one.
134
+ */
135
+ export async function ensureRequiredNetworks(
136
+ moduleId: string,
137
+ manifest: ModuleManifest,
138
+ db: DbClient,
139
+ ask: NetworkAsker = askText,
140
+ ): Promise<NetworkEnsureResult> {
141
+ // A `from:` requirement resolves against the module's OWN config, so the
142
+ // module's values have to be loaded before its required set is even knowable.
143
+ const names = getRequiredNetworkNames(manifest, loadModuleConfigValues(db, moduleId));
144
+ if (names.length === 0) return { success: true, applied: [] };
145
+
146
+ const schema = loadSchema();
147
+ const applied: string[] = [];
148
+
149
+ for (const name of names) {
150
+ const subnetKey = `network.${name}.subnet`;
151
+ const subnetProperty = schema.properties[subnetKey];
152
+ if (!subnetProperty) {
153
+ return {
154
+ success: false,
155
+ applied,
156
+ error: [
157
+ `Module "${moduleId}" requires a network called "${name}", which celilo does not know`,
158
+ `about: there is no "${subnetKey}" in celilo's system-config schema. Networks are`,
159
+ "celilo's to define, so a new one is added to schemas/system_config.json — a module",
160
+ 'cannot introduce one.',
161
+ ].join(' '),
162
+ };
163
+ }
164
+
165
+ // 1. The subnet — asked, because nothing in the fleet can be consulted for it.
166
+ //
167
+ // Whether this network already EXISTED is the fact everything below turns
168
+ // on: celilo is here to define a network, not to audit one it already holds.
169
+ let subnet = readSystemConfigValue(db, subnetKey);
170
+ const defining = subnet === undefined;
171
+ if (defining) {
172
+ const answer = (
173
+ await ask({
174
+ scope: `network:${name}`,
175
+ key: 'subnet',
176
+ message: `Subnet CIDR for the "${name}" network:`,
177
+ description: [
178
+ `${moduleId} requires the "${name}" network, and celilo has no subnet for it.`,
179
+ "This becomes celilo's definition of the network — every module that needs it reads",
180
+ 'this one value.',
181
+ ].join(' '),
182
+ defaultValue: subnetProperty.suggested,
183
+ // A defaultValue MUST have a matching placeholder, or the operator
184
+ // cannot see what pressing Enter would accept.
185
+ placeholder: subnetProperty.suggested,
186
+ required: true,
187
+ pattern: subnetProperty.pattern,
188
+ })
189
+ ).trim();
190
+
191
+ if (answer.length === 0) {
192
+ return {
193
+ success: false,
194
+ applied,
195
+ error: `No subnet supplied for the "${name}" network; ${moduleId} cannot deploy without it.`,
196
+ };
197
+ }
198
+ writeSystemConfigValue(db, subnetKey, answer);
199
+ applied.push(`${subnetKey} = ${answer}`);
200
+ subnet = answer;
201
+ }
202
+
203
+ // 2. The VLAN tag — asked, because it cannot be observed: a catalogued
204
+ // interface carries no tag. Optional: an untagged segment genuinely has
205
+ // none, and a blank answer writes nothing rather than writing "".
206
+ //
207
+ // ONLY when celilo is defining the network. An existing network was
208
+ // already defined without a tag — deliberately, or because it is
209
+ // untagged — and re-opening that question every time a new module
210
+ // requires it is not a question, it is a deploy that stops. Which is
211
+ // exactly what happened: `iptables` requires `internal`, whose subnet the
212
+ // management install had already recorded and whose vlan nothing ever
213
+ // set, so a headless deploy died on `interview.required.network:internal.vlan`
214
+ // with no responder to answer it. Absent means untagged; if that is wrong,
215
+ // `celilo system config set network.<name>.vlan <tag>` says so once.
216
+ const vlanKey = `network.${name}.vlan`;
217
+ if (
218
+ defining &&
219
+ schema.properties[vlanKey] &&
220
+ readSystemConfigValue(db, vlanKey) === undefined
221
+ ) {
222
+ const answer = (
223
+ await ask({
224
+ scope: `network:${name}`,
225
+ key: 'vlan',
226
+ message: `VLAN tag for the "${name}" network (blank if untagged):`,
227
+ description: [
228
+ 'Container provisioning reads this as the NIC tag, so a tagged fleet that leaves it',
229
+ 'unset puts containers on the wrong VLAN without saying so. Leave it blank if this',
230
+ 'segment is untagged.',
231
+ ].join(' '),
232
+ required: false,
233
+ })
234
+ ).trim();
235
+ if (answer.length > 0) {
236
+ writeSystemConfigValue(db, vlanKey, answer);
237
+ applied.push(`${vlanKey} = ${answer}`);
238
+ }
239
+ }
240
+
241
+ // 3. The gateway — OBSERVED. celilo catalogues every machine's interfaces at
242
+ // `machine add`, so the router's address inside this subnet is a fact it
243
+ // already holds. Absent means no router leg is catalogued there yet, which
244
+ // a later `machine add` or firewall deploy resolves; it is not a question.
245
+ const gatewayKey = `network.${name}.gateway`;
246
+ if (
247
+ subnet &&
248
+ schema.properties[gatewayKey] &&
249
+ readSystemConfigValue(db, gatewayKey) === undefined
250
+ ) {
251
+ const observed = observeGateway(db, subnet);
252
+ if (observed) {
253
+ writeSystemConfigValue(db, gatewayKey, observed);
254
+ applied.push(`${gatewayKey} = ${observed} (observed from the machine catalogue)`);
255
+ }
256
+ }
257
+ }
258
+
259
+ return { success: true, applied };
260
+ }
@@ -15,7 +15,7 @@ import { systemConfig } from '../db/schema';
15
15
  /**
16
16
  * System configuration schema interface
17
17
  */
18
- interface SystemConfigSchema {
18
+ export interface SystemConfigSchema {
19
19
  properties: Record<
20
20
  string,
21
21
  {
@@ -26,6 +26,17 @@ interface SystemConfigSchema {
26
26
  minimum?: number;
27
27
  maximum?: number;
28
28
  format?: string;
29
+ /**
30
+ * A starting offer for an interview, deliberately NOT a `default`.
31
+ *
32
+ * `getDefaultConfiguration()` seeds every `default:` it finds at `system
33
+ * init`, and network addressing is specifically not seeded
34
+ * (openspec/specs/progressive-zone-disclosure/spec.md). A separate field
35
+ * is structurally incapable of becoming a row nobody chose: it is only
36
+ * ever read to pre-fill a question the operator still has to answer
37
+ * ([[services/network-ensure.ts]]).
38
+ */
39
+ suggested?: string;
29
40
  }
30
41
  >;
31
42
  }
@@ -33,7 +44,7 @@ interface SystemConfigSchema {
33
44
  /**
34
45
  * Load system config schema from JSON file
35
46
  */
36
- function loadSchema(): SystemConfigSchema {
47
+ export function loadSchema(): SystemConfigSchema {
37
48
  // Try common locations (relative to this file's directory and cwd)
38
49
  const thisDir = dirname(new URL(import.meta.url).pathname);
39
50
  const candidates = [
@@ -80,7 +91,7 @@ export function getDefaultConfiguration(): Record<string, string | number> {
80
91
  * @param subnet - CIDR notation (e.g., "10.0.10.0/24")
81
92
  * @returns Gateway IP (e.g., "10.0.10.1")
82
93
  */
83
- function computeGateway(subnet: string): string {
94
+ export function computeGateway(subnet: string): string {
84
95
  const [network, _bits] = subnet.split('/');
85
96
  const octets = network.split('.').map(Number);
86
97
 
@@ -42,6 +42,7 @@ import { type DbClient, createDbClient } from '../db/client';
42
42
  import type {
43
43
  ConfigRequiredPayload,
44
44
  EnsureRequiredPayload,
45
+ InterviewRequiredPayload,
45
46
  SecretRequiredPayload,
46
47
  } from '../services/bus-interview';
47
48
  import {
@@ -79,6 +80,8 @@ export interface BusResponderFixture {
79
80
  seenSecretPayloads(): SecretRequiredPayload[];
80
81
  /** Snapshot of every ensure.required payload the responder saw. */
81
82
  seenEnsurePayloads(): EnsureRequiredPayload[];
83
+ /** Snapshot of every generic interview.required payload the responder saw. */
84
+ seenInterviewPayloads(): InterviewRequiredPayload[];
82
85
  /** Stop watching and close the bus + db connections. */
83
86
  close(): void;
84
87
  }
@@ -102,7 +105,16 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
102
105
  const handle: ProgrammaticResponderHandle = startProgrammaticResponder({
103
106
  busDbPath: opts.busDbPath,
104
107
  db,
105
- values: { config: opts.config, secrets: opts.secrets, ensures: opts.ensures },
108
+ // Every family the fixture's options type accepts must be forwarded, or a
109
+ // test supplies an answer that silently never arrives and the deploy hangs
110
+ // on a question nobody is listening for.
111
+ values: {
112
+ config: opts.config,
113
+ secrets: opts.secrets,
114
+ ensures: opts.ensures,
115
+ interview: opts.interview,
116
+ aspects: opts.aspects,
117
+ },
106
118
  onMissing: 'throw',
107
119
  emittedBy: 'test-bus-responder',
108
120
  });
@@ -114,6 +126,7 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
114
126
  seenConfigPayloads: () => handle.seenConfigPayloads(),
115
127
  seenSecretPayloads: () => handle.seenSecretPayloads(),
116
128
  seenEnsurePayloads: () => handle.seenEnsurePayloads(),
129
+ seenInterviewPayloads: () => handle.seenInterviewPayloads(),
117
130
  close: () => {
118
131
  handle.close();
119
132
  db.$client.close();
@@ -185,9 +185,14 @@ function resolveSelfRefsInObject(
185
185
  }
186
186
 
187
187
  /**
188
- * Build resolution context for a module
188
+ * Build the resolution context for a module, provisioning as it goes.
189
189
  *
190
- * Execution function (Rule 10.1) - performs database queries
190
+ * Execution function (Rule 10.1) - performs database queries AND writes:
191
+ * it seeds `module_configs` with defaults and zone-derived networking, and
192
+ * records the module's deployed system (allocating IPAM addresses when the
193
+ * host is a celilo-provisioned container). This is the generate/deploy-time
194
+ * entrypoint. Anything that only wants to READ the resolved configuration
195
+ * wants {@link readResolutionContext} instead.
191
196
  *
192
197
  * @param moduleId - Module to build context for
193
198
  * @param db - Database client (optional, for testing)
@@ -197,6 +202,55 @@ export async function buildResolutionContext(
197
202
  moduleId: string,
198
203
  db = getDb(),
199
204
  ): Promise<ResolutionContext> {
205
+ return assembleResolutionContext(moduleId, db, { provision: true });
206
+ }
207
+
208
+ /**
209
+ * The same resolved configuration, computed without touching the database.
210
+ *
211
+ * Every derived value is recomputed from its current upstream, exactly as a
212
+ * build would compute it, but nothing is written: no config rows are seeded,
213
+ * no addresses are allocated, no deployed system is recorded. That makes it
214
+ * safe to call from read paths that run on a cadence — health checks, hook
215
+ * invocations, capability factories — where a provisioning side effect would
216
+ * be both surprising and, in the IPAM case, harmful.
217
+ *
218
+ * This is what {@link import('../hooks/load-hook-config').loadHookConfigMap}
219
+ * uses so a hook sees the value a derive currently produces rather than only
220
+ * the ones that happen to have been stored.
221
+ *
222
+ * @param moduleId - Module to build context for
223
+ * @param db - Database client (optional, for testing)
224
+ * @returns Resolution context with all data sources
225
+ */
226
+ export async function readResolutionContext(
227
+ moduleId: string,
228
+ db = getDb(),
229
+ ): Promise<ResolutionContext> {
230
+ return assembleResolutionContext(moduleId, db, { provision: false });
231
+ }
232
+
233
+ /**
234
+ * The shared body of both entrypoints. `provision` is deliberately private
235
+ * (Rule 10.3): callers choose a named function, not a flag.
236
+ */
237
+ async function assembleResolutionContext(
238
+ moduleId: string,
239
+ db: DbClient,
240
+ { provision }: { provision: boolean },
241
+ ): Promise<ResolutionContext> {
242
+ /**
243
+ * Seed a config row — a no-op when only reading. Every seeded value is also
244
+ * assigned into `selfConfig` by the caller, so the resolved context is the
245
+ * same either way; what differs is whether it is written down.
246
+ */
247
+ const persistConfig = (
248
+ key: string,
249
+ value: string | number | boolean | unknown[] | Record<string, unknown>,
250
+ ): void => {
251
+ if (provision) upsertModuleConfig(db, moduleId, key, value);
252
+ };
253
+
200
254
  // Fetch module manifest for VM resources
201
255
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
202
256
 
@@ -226,12 +280,12 @@ export async function buildResolutionContext(
226
280
 
227
281
  // Store assigned values in module config
228
282
  if (assigned.hostname) {
229
- upsertModuleConfig(db, moduleId, 'hostname', assigned.hostname);
283
+ persistConfig('hostname', assigned.hostname);
230
284
  selfConfig.hostname = assigned.hostname;
231
285
  }
232
286
 
233
287
  if (assigned.zone) {
234
- upsertModuleConfig(db, moduleId, 'zone', assigned.zone);
288
+ persistConfig('zone', assigned.zone);
235
289
  selfConfig.zone = assigned.zone;
236
290
  }
237
291
  }
@@ -248,9 +302,7 @@ export async function buildResolutionContext(
248
302
  // declared shape — e.g. `default: 2222` (YAML int) round-trips as
249
303
  // `number` not the string "2222". This is the root of Defect 1.
250
304
  if (variable.default !== undefined && !selfConfig[variable.name]) {
251
- upsertModuleConfig(
252
- db,
253
- moduleId,
305
+ persistConfig(
254
306
  variable.name,
255
307
  variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
256
308
  );
@@ -303,7 +355,7 @@ export async function buildResolutionContext(
303
355
  for (const { manifestKey, configKey, systemValue } of resourceMappings) {
304
356
  if (systemValue != null) {
305
357
  // Canonical system size — always wins so a resize propagates.
306
- upsertModuleConfig(db, moduleId, configKey, systemValue);
358
+ persistConfig(configKey, systemValue);
307
359
  selfConfig[configKey] = String(systemValue);
308
360
  continue;
309
361
  }
@@ -312,9 +364,7 @@ export async function buildResolutionContext(
312
364
  // Pass them through unstringified so valueJson preserves the
313
365
  // shape — see comment in the variable-defaults block above.
314
366
  if (value !== undefined && !selfConfig[configKey]) {
315
- upsertModuleConfig(
316
- db,
317
- moduleId,
367
+ persistConfig(
318
368
  configKey,
319
369
  value as string | number | boolean | unknown[] | Record<string, unknown>,
320
370
  );
@@ -333,7 +383,11 @@ export async function buildResolutionContext(
333
383
  // outputs (resolveInfrastructureVariables), not here.
334
384
  // This is the single place generate-time addresses are recorded — `target_ip`
335
385
  // no longer lives in module_configs.
336
- if (module?.manifestData) {
386
+ //
387
+ // Provisioning only. A read must never reach this: allocating an address is
388
+ // not something looking at a config should do, and by the time any hook runs
389
+ // the row is already there for `buildInfraSystemsMap` below to read.
390
+ if (provision && module?.manifestData) {
337
391
  const manifest = module.manifestData as ModuleManifest;
338
392
  const declared = getDeclaredSystems(manifest);
339
393
  const hostname = selfConfig.hostname;
@@ -593,7 +647,7 @@ export async function buildResolutionContext(
593
647
 
594
648
  // If zone from manifest but not in selfConfig, store it as first-class config
595
649
  if (zone && !selfConfig.zone) {
596
- upsertModuleConfig(db, moduleId, 'zone', zone);
650
+ persistConfig('zone', zone);
597
651
  selfConfig.zone = zone;
598
652
  }
599
653
 
@@ -625,7 +679,7 @@ export async function buildResolutionContext(
625
679
  }
626
680
  return value;
627
681
  })();
628
- upsertModuleConfig(db, moduleId, field, coerced);
682
+ persistConfig(field, coerced);
629
683
  selfConfig[field] = String(coerced);
630
684
  }
631
685
  }
@@ -685,7 +739,7 @@ export async function buildResolutionContext(
685
739
  }
686
740
 
687
741
  if (isNew || isChanged) {
688
- upsertModuleConfig(db, moduleId, key, value);
742
+ persistConfig(key, value);
689
743
  selfConfig[key] = value;
690
744
  }
691
745
  }
@@ -52,6 +52,59 @@ describe('resolveDeclarativeDerivation', () => {
52
52
  expect(result).toBe('10.0.10.0/24');
53
53
  });
54
54
 
55
+ test('resolves a system key containing hyphens', () => {
56
+ // celilo's zone names are kebab-case, so shipped manifests contain
57
+ // `$system:network.control-plane-vpn.subnet` and
58
+ // `$system:network.secure-mgmt.subnet`. While the match stopped at the
59
+ // first hyphen these looked up `network.control` / `network.secure`,
60
+ // threw, and — being optional — resolved to nothing in silence. That is
61
+ // half of the 2026-08-14 DNS outage.
62
+ const variable: VariableDeclare = {
63
+ name: 'vpn_subnet',
64
+ type: 'string',
65
+ required: false,
66
+ source: 'system',
67
+ derive_from: '$system:network.control-plane-vpn.subnet',
68
+ };
69
+
70
+ const context: ResolutionContext = {
71
+ moduleId: 'technitium',
72
+ selfConfig: {},
73
+ systemConfig: { 'network.control-plane-vpn.subnet': '10.255.255.0/24' },
74
+ systemSecrets: {},
75
+ secrets: {},
76
+ capabilities: {},
77
+ };
78
+
79
+ expect(resolveDeclarativeDerivation(variable, context)).toBe('10.255.255.0/24');
80
+ });
81
+
82
+ test('names the whole hyphenated key when it is missing', () => {
83
+ // The message has to name the key the manifest asked for. Reporting
84
+ // `network.secure` for a manifest that says `network.secure-mgmt` sends
85
+ // the reader looking for a key that was never requested.
86
+ const variable: VariableDeclare = {
87
+ name: 'secure_mgmt_subnet',
88
+ type: 'string',
89
+ required: true,
90
+ source: 'system',
91
+ derive_from: '$system:network.secure-mgmt.subnet',
92
+ };
93
+
94
+ const context: ResolutionContext = {
95
+ moduleId: 'technitium',
96
+ selfConfig: {},
97
+ systemConfig: {},
98
+ systemSecrets: {},
99
+ secrets: {},
100
+ capabilities: {},
101
+ };
102
+
103
+ expect(() => resolveDeclarativeDerivation(variable, context)).toThrow(
104
+ "Missing system config: network.secure-mgmt.subnet (required by variable 'secure_mgmt_subnet')",
105
+ );
106
+ });
107
+
55
108
  test('throws on missing system config', () => {
56
109
  const variable: VariableDeclare = {
57
110
  name: 'primary_domain',
@@ -56,8 +56,19 @@ function substituteVariables(
56
56
  ): string {
57
57
  let result = input;
58
58
 
59
- // Replace $system:key patterns (both $system:key and ${system:key} forms)
60
- result = result.replace(/\$\{?system:([a-zA-Z0-9_.]+)\}?/g, (_match, key) => {
59
+ // Replace $system:key patterns (both $system:key and ${system:key} forms).
60
+ //
61
+ // The key may contain hyphens. celilo's own zone names are kebab-case, so
62
+ // `$system:network.control-plane-vpn.subnet` and
63
+ // `$system:network.secure-mgmt.subnet` are both real keys in shipped
64
+ // manifests — and neither could ever resolve while this class excluded `-`:
65
+ // the match stopped at the first hyphen, looked up `network.control`, and
66
+ // threw. For an optional variable that throw is swallowed, so the derive
67
+ // simply produced nothing, forever, in silence. That is half of the
68
+ // 2026-08-14 DNS outage (`technitium.vpn_subnet`); the other half is that
69
+ // nothing re-derived the value at read time — see
70
+ // `hooks/load-hook-config.ts`.
71
+ result = result.replace(/\$\{?system:([a-zA-Z0-9_.-]+)\}?/g, (_match, key) => {
61
72
  const value = context.systemConfig[key];
62
73
  if (value === undefined) {
63
74
  throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`);