@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +3 -1
- package/package.json +1 -1
- package/schemas/system_config.json +10 -5
- package/src/cli/commands/system-apply-config-equivalence.test.ts +46 -8
- package/src/cli/commands/system-apply-config.test.ts +57 -1
- package/src/cli/commands/system-apply-config.ts +43 -0
- package/src/cli/commands/system-discover-network.ts +35 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +9 -0
- package/src/hooks/capability-loader.ts +33 -17
- package/src/manifest/network-requirement-schema.test.ts +141 -0
- package/src/manifest/schema.ts +124 -0
- package/src/services/module-deploy.ts +31 -3
- package/src/services/network-discovery.test.ts +198 -0
- package/src/services/network-discovery.ts +164 -0
- package/src/services/network-ensure.test.ts +324 -0
- package/src/services/network-ensure.ts +260 -0
- package/src/services/system-init.ts +14 -3
- package/src/test-utils/bus-responder.ts +14 -1
|
@@ -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
|
-
|
|
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();
|