@celilo/cli 0.24.1 → 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,324 @@
1
+ /**
2
+ * `requires.networks` is ensured before the deploy proceeds
3
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
4
+ *
5
+ * The asker is injected, so these assert the RULES — which questions get asked,
6
+ * what gets written, what is left alone — without a bus or a responder.
7
+ */
8
+
9
+ import { beforeEach, describe, expect, test } from 'bun:test';
10
+ import { eq } from 'drizzle-orm';
11
+ import type { DbClient } from '../db/client';
12
+ import { machines, systemConfig } from '../db/schema';
13
+ import type { ModuleManifest } from '../manifest/schema';
14
+ import { setupTestDatabase } from '../test-utils/database';
15
+ import { type NetworkAsker, ensureRequiredNetworks } from './network-ensure';
16
+
17
+ function manifestRequiring(...networks: string[]): ModuleManifest {
18
+ return {
19
+ celilo_contract: '1.0',
20
+ id: 'test-module',
21
+ name: 'Test Module',
22
+ version: '0.1.0',
23
+ requires: {
24
+ capabilities: [],
25
+ networks: networks.map((name) => ({ name })),
26
+ },
27
+ provides: { capabilities: [] },
28
+ variables: { owns: [], imports: [] },
29
+ } as unknown as ModuleManifest;
30
+ }
31
+
32
+ /** Records every question and answers each from `answers`, keyed `<scope>.<key>`. */
33
+ function recordingAsker(answers: Record<string, string>): {
34
+ ask: NetworkAsker;
35
+ asked: Array<{ scope: string; key: string; defaultValue?: string }>;
36
+ } {
37
+ const asked: Array<{ scope: string; key: string; defaultValue?: string }> = [];
38
+ const ask: NetworkAsker = async (question) => {
39
+ asked.push({
40
+ scope: question.scope,
41
+ key: question.key,
42
+ defaultValue: question.defaultValue,
43
+ });
44
+ const answer = answers[`${question.scope}.${question.key}`];
45
+ if (answer === undefined) {
46
+ throw new Error(`test asker has no answer for ${question.scope}.${question.key}`);
47
+ }
48
+ return answer;
49
+ };
50
+ return { ask, asked };
51
+ }
52
+
53
+ function readConfig(db: DbClient, key: string): string | undefined {
54
+ return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value;
55
+ }
56
+
57
+ type Iface = { name: string; ipAddress: string; zone: string };
58
+
59
+ /**
60
+ * Put a machine in the catalogue, the way `machine add` does.
61
+ *
62
+ * `role` is the load-bearing field here: only a router is a gateway. The
63
+ * recorded `zone` on an interface is deliberately left wrong/unknown in these
64
+ * fixtures, because that is the real state before a subnet has been declared —
65
+ * an interface cannot be classified into a zone that does not exist yet.
66
+ */
67
+ function seedMachine(db: DbClient, role: 'host' | 'router', interfaces: Iface[]): void {
68
+ db.insert(machines)
69
+ .values({
70
+ id: `machine-${role}-${interfaces[0]?.ipAddress ?? 'none'}`,
71
+ hostname: `${role}-box`,
72
+ ipAddress: interfaces[0]?.ipAddress ?? '127.0.0.1',
73
+ sshUser: 'root',
74
+ sshKeyEncrypted: 'x',
75
+ zone: 'internal',
76
+ hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 10 },
77
+ role,
78
+ interfaces,
79
+ })
80
+ .run();
81
+ }
82
+
83
+ function seedRouter(db: DbClient, interfaces: Iface[]): void {
84
+ seedMachine(db, 'router', interfaces);
85
+ }
86
+
87
+ function seedHost(db: DbClient, interfaces: Iface[]): void {
88
+ seedMachine(db, 'host', interfaces);
89
+ }
90
+
91
+ describe('ensureRequiredNetworks', () => {
92
+ let db: DbClient;
93
+
94
+ beforeEach(async () => {
95
+ db = await setupTestDatabase();
96
+ });
97
+
98
+ test('a module requiring no network asks nothing', async () => {
99
+ const { ask, asked } = recordingAsker({});
100
+ const result = await ensureRequiredNetworks('test-module', manifestRequiring(), db, ask);
101
+
102
+ expect(result.success).toBe(true);
103
+ expect(asked).toEqual([]);
104
+ });
105
+
106
+ test('an undefined required network is asked for and written to system config', async () => {
107
+ const { ask, asked } = recordingAsker({
108
+ 'network:control-plane-vpn.subnet': '10.9.9.0/24',
109
+ });
110
+
111
+ const result = await ensureRequiredNetworks(
112
+ 'wireguard',
113
+ manifestRequiring('control-plane-vpn'),
114
+ db,
115
+ ask,
116
+ );
117
+
118
+ expect(result.success).toBe(true);
119
+ expect(asked.map((a) => `${a.scope}.${a.key}`)).toEqual(['network:control-plane-vpn.subnet']);
120
+ expect(readConfig(db, 'network.control-plane-vpn.subnet')).toBe('10.9.9.0/24');
121
+ });
122
+
123
+ test('a network that is already defined is left alone — no question, no rewrite', async () => {
124
+ db.insert(systemConfig)
125
+ .values({ key: 'network.control-plane-vpn.subnet', value: '10.7.7.0/24' })
126
+ .run();
127
+
128
+ const { ask, asked } = recordingAsker({});
129
+ const result = await ensureRequiredNetworks(
130
+ 'wireguard',
131
+ manifestRequiring('control-plane-vpn'),
132
+ db,
133
+ ask,
134
+ );
135
+
136
+ expect(result.success).toBe(true);
137
+ expect(asked).toEqual([]);
138
+ expect(readConfig(db, 'network.control-plane-vpn.subnet')).toBe('10.7.7.0/24');
139
+ });
140
+
141
+ /**
142
+ * Which attributes a network HAS is celilo's answer, and WHICH ARE QUESTIONS
143
+ * is a second answer on top of it. The config schema gives dmz a vlan and
144
+ * gives the control-plane VPN none, so the VPN is never asked a question that
145
+ * makes no sense for it — and the gateway is never asked of anything, because
146
+ * celilo can look it up.
147
+ */
148
+ test('vlan is asked only for the networks celilo says have one; gateway is never asked', async () => {
149
+ const { ask, asked } = recordingAsker({
150
+ 'network:dmz.subnet': '10.0.10.0/24',
151
+ 'network:dmz.vlan': '10',
152
+ 'network:control-plane-vpn.subnet': '10.9.9.0/24',
153
+ });
154
+
155
+ const result = await ensureRequiredNetworks(
156
+ 'iptables',
157
+ manifestRequiring('dmz', 'control-plane-vpn'),
158
+ db,
159
+ ask,
160
+ );
161
+
162
+ expect(result.success).toBe(true);
163
+ expect(asked.map((a) => `${a.scope}.${a.key}`)).toEqual([
164
+ 'network:dmz.subnet',
165
+ 'network:dmz.vlan',
166
+ 'network:control-plane-vpn.subnet',
167
+ ]);
168
+ expect(asked.map((a) => a.key)).not.toContain('gateway');
169
+ expect(readConfig(db, 'network.dmz.vlan')).toBe('10');
170
+ expect(readConfig(db, 'network.control-plane-vpn.vlan')).toBeUndefined();
171
+ });
172
+
173
+ /**
174
+ * The failure this test exists for, found on the rig.
175
+ *
176
+ * `iptables` requires `internal`, whose subnet the management install had
177
+ * already recorded and whose vlan nothing ever set. The ensure asked for the
178
+ * vlan, headlessly, with no responder attached, and the deploy died on
179
+ * `interview.required.network:internal.vlan` — a question about a network that
180
+ * had existed since the fleet was built.
181
+ *
182
+ * celilo is here to DEFINE a network, not to audit one it already holds. An
183
+ * existing network was already defined without a tag, deliberately or because
184
+ * it is untagged; re-opening that every time a new module requires it is not a
185
+ * question, it is a deploy that stops.
186
+ */
187
+ test('an existing network is never re-asked about, vlan included', async () => {
188
+ db.insert(systemConfig).values({ key: 'network.dmz.subnet', value: '10.0.10.0/24' }).run();
189
+
190
+ const { ask, asked } = recordingAsker({});
191
+ const result = await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask);
192
+
193
+ expect(result.success).toBe(true);
194
+ expect(asked).toEqual([]);
195
+ expect(readConfig(db, 'network.dmz.vlan')).toBeUndefined();
196
+ });
197
+
198
+ test('a blank vlan answer writes nothing — untagged is a real answer, not an empty tag', async () => {
199
+ const { ask } = recordingAsker({
200
+ 'network:dmz.subnet': '10.0.10.0/24',
201
+ 'network:dmz.vlan': '',
202
+ });
203
+
204
+ await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask);
205
+
206
+ expect(readConfig(db, 'network.dmz.subnet')).toBe('10.0.10.0/24');
207
+ expect(readConfig(db, 'network.dmz.vlan')).toBeUndefined();
208
+ });
209
+
210
+ /**
211
+ * The gateway is a fact about the fleet, not a decision: it is the address a
212
+ * router answers on inside the subnet, and `machine add` already catalogued it.
213
+ * Matched by CONTAINMENT rather than by the interface's recorded zone, because
214
+ * an interface has no zone until the subnet it sits in has been declared —
215
+ * which is the very thing that just happened one line earlier.
216
+ */
217
+ test('the gateway is observed from a catalogued router leg, never asked', async () => {
218
+ seedRouter(db, [{ name: 'eth1', ipAddress: '10.0.10.254', zone: 'unknown' }]);
219
+
220
+ const { ask, asked } = recordingAsker({
221
+ 'network:dmz.subnet': '10.0.10.0/24',
222
+ 'network:dmz.vlan': '',
223
+ });
224
+
225
+ await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask);
226
+
227
+ expect(asked.map((a) => a.key)).not.toContain('gateway');
228
+ // Not x.x.x.1 — the address the router actually holds.
229
+ expect(readConfig(db, 'network.dmz.gateway')).toBe('10.0.10.254');
230
+ });
231
+
232
+ test('a host inside the subnet is not mistaken for the gateway', async () => {
233
+ seedHost(db, [{ name: 'eth0', ipAddress: '10.0.10.50', zone: 'dmz' }]);
234
+
235
+ const { ask } = recordingAsker({
236
+ 'network:dmz.subnet': '10.0.10.0/24',
237
+ 'network:dmz.vlan': '',
238
+ });
239
+
240
+ await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask);
241
+
242
+ // Only a router is a gateway. Guessing from any host in the subnet would
243
+ // hand every container in that zone a default route to a random peer.
244
+ expect(readConfig(db, 'network.dmz.gateway')).toBeUndefined();
245
+ });
246
+
247
+ test('no catalogued router leg leaves the gateway unset rather than invented', async () => {
248
+ const { ask } = recordingAsker({
249
+ 'network:dmz.subnet': '10.0.10.0/24',
250
+ 'network:dmz.vlan': '',
251
+ });
252
+
253
+ await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask);
254
+
255
+ expect(readConfig(db, 'network.dmz.gateway')).toBeUndefined();
256
+ });
257
+
258
+ /**
259
+ * The offer is a starting point in a question, never a row nobody chose — so
260
+ * it has to reach the asker and it has to be VISIBLE. A defaultValue with no
261
+ * matching placeholder is a default the operator cannot see and cannot know
262
+ * they may accept with Enter.
263
+ */
264
+ test('a well-known name is offered a suggested range, visibly', async () => {
265
+ const asked: Array<{ key: string; defaultValue?: string; placeholder?: string }> = [];
266
+ const ask: NetworkAsker = async (question) => {
267
+ asked.push({
268
+ key: question.key,
269
+ defaultValue: question.defaultValue,
270
+ placeholder: question.placeholder,
271
+ });
272
+ return question.key === 'subnet' ? '10.44.0.0/24' : '';
273
+ };
274
+
275
+ const result = await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask);
276
+
277
+ expect(result.success).toBe(true);
278
+ expect(asked[0].key).toBe('subnet');
279
+ expect(asked[0].defaultValue).toBe('10.0.10.0/24');
280
+ expect(asked[0].placeholder).toBe(asked[0].defaultValue);
281
+ // The operator's answer wins over the offer, which is the whole point of
282
+ // offering rather than defaulting.
283
+ expect(readConfig(db, 'network.dmz.subnet')).toBe('10.44.0.0/24');
284
+ });
285
+
286
+ test('nothing is written when the module requires nothing — a deploy seeds no network', async () => {
287
+ const { ask } = recordingAsker({});
288
+ await ensureRequiredNetworks('test-module', manifestRequiring(), db, ask);
289
+
290
+ const networkRows = db
291
+ .select()
292
+ .from(systemConfig)
293
+ .all()
294
+ .filter((row) => row.key.startsWith('network.'));
295
+ expect(networkRows.filter((row) => row.key.endsWith('.subnet'))).toEqual([]);
296
+ });
297
+
298
+ test('requiring a network celilo has never heard of fails with an actionable message', async () => {
299
+ const { ask } = recordingAsker({});
300
+ const result = await ensureRequiredNetworks(
301
+ 'test-module',
302
+ manifestRequiring('not-a-network'),
303
+ db,
304
+ ask,
305
+ );
306
+
307
+ expect(result.success).toBe(false);
308
+ expect(result.error).toContain('not-a-network');
309
+ expect(result.error).toContain('system_config.json');
310
+ });
311
+
312
+ test('an empty answer fails the deploy rather than writing an empty network', async () => {
313
+ const ask: NetworkAsker = async () => ' ';
314
+ const result = await ensureRequiredNetworks(
315
+ 'wireguard',
316
+ manifestRequiring('control-plane-vpn'),
317
+ db,
318
+ ask,
319
+ );
320
+
321
+ expect(result.success).toBe(false);
322
+ expect(readConfig(db, 'network.control-plane-vpn.subnet')).toBeUndefined();
323
+ });
324
+ });
@@ -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();