@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,164 @@
1
+ /**
2
+ * celilo discovering the network of the box it is installed on.
3
+ *
4
+ * This used to live in `modules/celilo-mgmt/scripts/discovery.ts`, which parsed
5
+ * `ip route` and then shelled `celilo system apply-config network.internal.…`.
6
+ * That made the management module the author of a network definition — and
7
+ * networks are celilo's
8
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
9
+ *
10
+ * The distinction is worth stating, because it is the reason this moved rather
11
+ * than being exempted. A module writing `network.<name>.subnet` from its own
12
+ * config is CHOOSING a range: an authority it should not have. celilo-mgmt was
13
+ * not doing that — it was reading the kernel's routing table and reporting what
14
+ * it found. The value was never the module's opinion. But the mechanism was
15
+ * identical to the one being closed, and an exemption for "this caller is
16
+ * trustworthy" is not enforceable: nothing stopped any other module from making
17
+ * the same call. Moving the code makes the module's authority disappear instead
18
+ * of being promised away.
19
+ *
20
+ * So the discovery is celilo's, the write is celilo's, and celilo-mgmt asks for
21
+ * it by name.
22
+ */
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import { eq } from 'drizzle-orm';
26
+ import type { DbClient } from '../db/client';
27
+ import { systemConfig } from '../db/schema';
28
+
29
+ export interface DiscoveredNetwork {
30
+ subnet: string;
31
+ gateway: string;
32
+ }
33
+
34
+ /**
35
+ * The connected subnet + gateway of the interface carrying the default route.
36
+ *
37
+ * Pure, so it can be tested against real `ip route` output without a host.
38
+ * Returns null when either the default route or its connected (kernel/link)
39
+ * route is absent — celilo says it could not discover, rather than guessing.
40
+ */
41
+ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork | null {
42
+ const lines = ipRouteOutput.split('\n').map((l) => l.trim());
43
+ const defaultLine = lines.find((l) => l.startsWith('default '));
44
+ const match = defaultLine?.match(/^default via (\S+) dev (\S+)/);
45
+ if (!match) return null;
46
+ const gateway = match[1];
47
+ const dev = match[2];
48
+
49
+ const cidr = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;
50
+ const subnetLine = lines.find(
51
+ (l) => l.includes(`dev ${dev}`) && l.includes('proto kernel') && cidr.test(l.split(/\s+/)[0]),
52
+ );
53
+ if (!subnetLine) return null;
54
+ return { subnet: subnetLine.split(/\s+/)[0], gateway };
55
+ }
56
+
57
+ /**
58
+ * WHICH zone the discovered network is, which depends on the topology:
59
+ *
60
+ * - Single-network deployment (the common case): the box sits on the internal
61
+ * LAN, so what it discovered IS `internal`. First install records it there.
62
+ * - Segmented deployment: the box sits on a dedicated control-plane network.
63
+ * `internal` is then someone else's subnet — the semi-trusted LAN — and the
64
+ * discovered value belongs under `secure-mgmt`.
65
+ *
66
+ * Issue #300 spotted the second case as "discovery returns the WRONG subnet" and
67
+ * mitigated it by DISCARDING the value whenever `internal` was already set. That
68
+ * kept `internal` correct and left celilo blind to its own network, which is what
69
+ * breaks control-plane firewall trust and split-horizon DNS: the resolver has no
70
+ * view for an unrecognized source, so it answers NOERROR with zero records and
71
+ * the name falls through to public DNS. Recorded as `secure-mgmt` instead.
72
+ *
73
+ * Never clobbers an already-set value in either zone: discovery is a first-install
74
+ * fallback, not an override.
75
+ */
76
+ export function discoveredNetworkKeys(
77
+ host: DiscoveredNetwork | null,
78
+ internalAlreadySet: boolean,
79
+ opts: { internalSubnet?: string; secureMgmtAlreadySet?: boolean } = {},
80
+ ): Record<string, string> {
81
+ if (!host) return {};
82
+
83
+ if (!internalAlreadySet) {
84
+ return {
85
+ 'network.internal.subnet': host.subnet,
86
+ 'network.internal.gateway': host.gateway,
87
+ };
88
+ }
89
+
90
+ const onInternal = opts.internalSubnet === undefined || opts.internalSubnet === host.subnet;
91
+ if (onInternal || opts.secureMgmtAlreadySet) return {};
92
+
93
+ return {
94
+ 'network.secure-mgmt.subnet': host.subnet,
95
+ 'network.secure-mgmt.gateway': host.gateway,
96
+ };
97
+ }
98
+
99
+ /** Read the host's routing table. Injectable so the command is testable. */
100
+ export type RouteReader = () => string | null;
101
+
102
+ const readRoutes: RouteReader = () => {
103
+ try {
104
+ return execFileSync('ip', ['route'], { encoding: 'utf-8' });
105
+ } catch {
106
+ return null;
107
+ }
108
+ };
109
+
110
+ export interface NetworkDiscoveryResult {
111
+ /** Keys written, `<key> = <value>`, for the operator to read back. */
112
+ applied: string[];
113
+ /** Set when nothing could be discovered, with the reason. */
114
+ skipped?: string;
115
+ }
116
+
117
+ function readValue(db: DbClient, key: string): string | undefined {
118
+ return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined;
119
+ }
120
+
121
+ /**
122
+ * Discover the network this box sits on and record it. Idempotent, and never
123
+ * overwrites a value that is already set.
124
+ */
125
+ export function discoverAndRecordNetwork(
126
+ db: DbClient,
127
+ readRoutesImpl: RouteReader = readRoutes,
128
+ ): NetworkDiscoveryResult {
129
+ const routes = readRoutesImpl();
130
+ if (routes === null) {
131
+ return { applied: [], skipped: 'could not read the routing table (`ip route` failed)' };
132
+ }
133
+
134
+ const host = parseInternalNetwork(routes);
135
+ if (!host) {
136
+ return {
137
+ applied: [],
138
+ skipped: 'no default route with a connected subnet was found in `ip route`',
139
+ };
140
+ }
141
+
142
+ const internalSubnet = readValue(db, 'network.internal.subnet');
143
+ const keys = discoveredNetworkKeys(host, internalSubnet !== undefined, {
144
+ internalSubnet,
145
+ secureMgmtAlreadySet: readValue(db, 'network.secure-mgmt.subnet') !== undefined,
146
+ });
147
+
148
+ const applied: string[] = [];
149
+ for (const [key, value] of Object.entries(keys)) {
150
+ db.insert(systemConfig)
151
+ .values({ key, value })
152
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
153
+ .run();
154
+ applied.push(`${key} = ${value}`);
155
+ }
156
+
157
+ if (applied.length === 0) {
158
+ return {
159
+ applied,
160
+ skipped: `this box is on ${host.subnet}, which celilo already accounts for — nothing to record`,
161
+ };
162
+ }
163
+ return { applied };
164
+ }
@@ -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
+ });