@celilo/cli 0.24.1 → 0.25.1

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
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The DNS-ingress allocate-and-reserve guard (ISS-0156).
3
+ *
4
+ * This invariant had NO test. Losing it is not a crash: `module generate`
5
+ * re-allocates a different address on every run, silently moving the address
6
+ * internal clients use to reach DNS, while every command still reports success.
7
+ * `module generate` runs repeatedly over a module's life, so "on the second
8
+ * run" is the normal case, not an edge one.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, test } from 'bun:test';
12
+ import type { DbClient } from '../db/client';
13
+ import type { ModuleManifest } from '../manifest/schema';
14
+ import { getModuleConfigValue } from '../services/module-config';
15
+ import { setupTestDatabase } from '../test-utils/database';
16
+ import { ensureDnsIngressIp } from './generator';
17
+
18
+ let db: DbClient;
19
+
20
+ /** A manifest that opts in the way a `dns_internal` provider does. */
21
+ const wantsIngress = {
22
+ variables: { owns: [{ name: 'dns_ingress_ip', source: 'infrastructure' }] },
23
+ } as unknown as ModuleManifest;
24
+
25
+ /** Same shape, but the variable is operator input rather than infrastructure. */
26
+ const operatorSupplied = {
27
+ variables: { owns: [{ name: 'dns_ingress_ip', source: 'user_input' }] },
28
+ } as unknown as ModuleManifest;
29
+
30
+ /**
31
+ * Typed as `string | undefined` rather than `unknown`: every assertion here is
32
+ * about an address, and an untyped read pushes a cast onto each one.
33
+ */
34
+ const storedIp = (moduleId: string): string | undefined => {
35
+ const value = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
36
+ return typeof value === 'string' ? value : undefined;
37
+ };
38
+
39
+ beforeEach(async () => {
40
+ db = await setupTestDatabase();
41
+ db.$client
42
+ .prepare('INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)')
43
+ .run('network.internal.subnet', '10.226.1.0/24');
44
+ });
45
+
46
+ describe('ensureDnsIngressIp', () => {
47
+ test('allocates an address from the internal subnet on first generate', async () => {
48
+ const result = await ensureDnsIngressIp('technitium', wantsIngress, db);
49
+
50
+ expect(result.success).toBe(true);
51
+ expect(storedIp('technitium')).toMatch(/^10\.226\.1\.\d+$/);
52
+ });
53
+
54
+ test('REUSES the same address on a second generate', async () => {
55
+ // The guard itself. Re-allocating here moves the resolver's DNAT ingress
56
+ // every time the module is regenerated, and nothing reports a problem.
57
+ await ensureDnsIngressIp('technitium', wantsIngress, db);
58
+ const first = storedIp('technitium');
59
+
60
+ await ensureDnsIngressIp('technitium', wantsIngress, db);
61
+ const second = storedIp('technitium');
62
+
63
+ expect(second).toBe(first);
64
+ });
65
+
66
+ test('stays stable across many generates, not just two', async () => {
67
+ await ensureDnsIngressIp('technitium', wantsIngress, db);
68
+ const first = storedIp('technitium');
69
+
70
+ for (let i = 0; i < 5; i++) {
71
+ await ensureDnsIngressIp('technitium', wantsIngress, db);
72
+ }
73
+
74
+ expect(storedIp('technitium')).toBe(first);
75
+ });
76
+
77
+ test('RESERVES the address, so it is never handed out to something else', async () => {
78
+ // Allocation without reservation is the same bug one step later: a
79
+ // container gets the resolver's ingress address and DNS goes dark.
80
+ await ensureDnsIngressIp('technitium', wantsIngress, db);
81
+ const ip = storedIp('technitium');
82
+
83
+ const reserved = db.$client
84
+ .prepare('SELECT ip_start, reason FROM ip_reservations WHERE ip_start = ?')
85
+ .get(ip ?? '') as { ip_start: string; reason: string } | undefined;
86
+
87
+ expect(reserved?.ip_start).toBe(ip);
88
+ // The reason names the owner, so an operator reading the table can tell
89
+ // what an otherwise anonymous held address is for.
90
+ expect(reserved?.reason).toBe('dns-ingress:technitium');
91
+ });
92
+
93
+ test('two modules get two different addresses', async () => {
94
+ await ensureDnsIngressIp('technitium', wantsIngress, db);
95
+ await ensureDnsIngressIp('knot-unbound-internal', wantsIngress, db);
96
+
97
+ expect(storedIp('knot-unbound-internal')).not.toBe(storedIp('technitium'));
98
+ });
99
+
100
+ test('does nothing for a module that never asked for one', async () => {
101
+ const result = await ensureDnsIngressIp('caddy', {} as ModuleManifest, db);
102
+
103
+ expect(result.success).toBe(true);
104
+ expect(storedIp('caddy')).toBeUndefined();
105
+ });
106
+
107
+ test('only `source: infrastructure` opts in', async () => {
108
+ // A same-named variable the operator supplies is theirs to set; allocating
109
+ // over it would overwrite an operator's deliberate choice.
110
+ await ensureDnsIngressIp('technitium', operatorSupplied, db);
111
+
112
+ expect(storedIp('technitium')).toBeUndefined();
113
+ });
114
+
115
+ test('fails with an actionable message when the internal subnet is unset', async () => {
116
+ db.$client.prepare('DELETE FROM system_config WHERE key = ?').run('network.internal.subnet');
117
+
118
+ const result = await ensureDnsIngressIp('technitium', wantsIngress, db);
119
+
120
+ expect(result.success).toBe(false);
121
+ expect(result.success === false && result.error).toContain('network.internal.subnet');
122
+ });
123
+ });
@@ -653,6 +653,70 @@ celilo module import modules/${moduleId}
653
653
  * @param options - Generation options
654
654
  * @returns Generation result
655
655
  */
656
+ /** Narrower than `GenerateResult`: this step produces no files, only an outcome. */
657
+ export type DnsIngressResult = { success: true } | { success: false; error: string };
658
+
659
+ /**
660
+ * Allocate-and-reserve the module's dedicated DNS-ingress IP, ONCE (ISS-0156).
661
+ *
662
+ * A `dns_internal` provider deploys into a PROTECTED zone (dmz) so it can see
663
+ * protected-zone query sources for split-horizon views. `internal` devices have
664
+ * no route into the 10-net, so they reach the resolver through a firewall DNAT
665
+ * on a dedicated `internal`-subnet address. A module opts in by declaring a
666
+ * `dns_ingress_ip` infrastructure variable.
667
+ *
668
+ * **Idempotence is the whole point, and it is load-bearing.** `module generate`
669
+ * runs repeatedly over a module's life. Re-allocating here on the second run
670
+ * would move the address internal clients use to reach DNS, every time — while
671
+ * every command still reports success. That is why the stored value is reused
672
+ * rather than re-derived, and why this is a named function instead of a branch
673
+ * buried in `generateTemplates`: an invariant nothing can call is an invariant
674
+ * nothing can test, and this one had no test at all.
675
+ */
676
+ export async function ensureDnsIngressIp(
677
+ moduleId: string,
678
+ manifest: ModuleManifest,
679
+ db: DbClient,
680
+ ): Promise<DnsIngressResult> {
681
+ const wantsDnsIngress = manifest.variables?.owns?.some(
682
+ (v) => v.name === 'dns_ingress_ip' && v.source === 'infrastructure',
683
+ );
684
+ if (!wantsDnsIngress) return { success: true };
685
+
686
+ const existing = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
687
+ if (typeof existing === 'string' && existing.length > 0) {
688
+ log.success(`Using existing DNS-ingress IP ${existing} for ${moduleId}`);
689
+ return { success: true };
690
+ }
691
+
692
+ const subnetRow = db.$client
693
+ .prepare('SELECT value FROM system_config WHERE key = ?')
694
+ .get('network.internal.subnet') as { value: string } | undefined;
695
+ if (!subnetRow?.value) {
696
+ return {
697
+ success: false,
698
+ error:
699
+ 'network.internal.subnet is not configured — required to allocate the ' +
700
+ 'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
701
+ };
702
+ }
703
+
704
+ const { allocateIPFromSubnet, reserveIP } = await import('../ipam/allocator');
705
+ const { stripCIDR } = await import('../ipam/subnet-parser');
706
+ try {
707
+ const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
708
+ await reserveIP(ip, 'internal', `dns-ingress:${moduleId}`, null, db);
709
+ upsertModuleConfig(db, moduleId, 'dns_ingress_ip', ip);
710
+ log.success(`Allocated DNS-ingress IP ${ip} (internal subnet) for ${moduleId}`);
711
+ return { success: true };
712
+ } catch (error) {
713
+ return {
714
+ success: false,
715
+ error: `DNS-ingress IP allocation failed: ${error instanceof Error ? error.message : String(error)}`,
716
+ };
717
+ }
718
+ }
719
+
656
720
  export async function generateTemplates(options: GenerateOptions): Promise<GenerateResult> {
657
721
  const { moduleId, modulePath, outputPath, db = getDb() } = options;
658
722
 
@@ -815,49 +879,8 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
815
879
  }
816
880
  }
817
881
 
818
- // Dedicated DNS-ingress IP (ISS-0156). A dns_internal provider now deploys into
819
- // a PROTECTED zone (dmz) so it can see protected-zone query sources for
820
- // split-horizon views (openspec/specs/internal-dns-zone-views/spec.md). `internal` devices have
821
- // no route into the 10-net, so they reach the resolver through a firewall DNAT
822
- // on a dedicated `internal`-subnet address. A module opts in by declaring a
823
- // `dns_ingress_ip` infrastructure variable; we allocate a free IP from the
824
- // `internal` subnet via IPAM and RESERVE it (so it's never re-handed-out),
825
- // idempotently (reuse the stored value on re-generate). The resolver's
826
- // on_install passes it to firewall.exposeService({ ingressIp }).
827
- const wantsDnsIngress = manifest.variables?.owns?.some(
828
- (v) => v.name === 'dns_ingress_ip' && v.source === 'infrastructure',
829
- );
830
- if (wantsDnsIngress) {
831
- const existing = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
832
- if (typeof existing === 'string' && existing.length > 0) {
833
- log.success(`Using existing DNS-ingress IP ${existing} for ${moduleId}`);
834
- } else {
835
- const subnetRow = db.$client
836
- .prepare('SELECT value FROM system_config WHERE key = ?')
837
- .get('network.internal.subnet') as { value: string } | undefined;
838
- if (!subnetRow?.value) {
839
- return {
840
- success: false,
841
- error:
842
- 'network.internal.subnet is not configured — required to allocate the ' +
843
- 'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
844
- };
845
- }
846
- const { allocateIPFromSubnet, reserveIP } = await import('../ipam/allocator');
847
- const { stripCIDR } = await import('../ipam/subnet-parser');
848
- try {
849
- const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
850
- await reserveIP(ip, 'internal', `dns-ingress:${moduleId}`, null, db);
851
- upsertModuleConfig(db, moduleId, 'dns_ingress_ip', ip);
852
- log.success(`Allocated DNS-ingress IP ${ip} (internal subnet) for ${moduleId}`);
853
- } catch (error) {
854
- return {
855
- success: false,
856
- error: `DNS-ingress IP allocation failed: ${error instanceof Error ? error.message : String(error)}`,
857
- };
858
- }
859
- }
860
- }
882
+ const dnsIngress = await ensureDnsIngressIp(moduleId, manifest, db);
883
+ if (!dnsIngress.success) return dnsIngress;
861
884
 
862
885
  // Infrastructure Properties Resolution (Proxmox provider config)
863
886
  // For Proxmox services, extract provider config and store as temporary values
@@ -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();