@celilo/cli 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/module-config.test.ts +6 -1
- package/src/cli/commands/module-config.ts +103 -20
- package/src/cli/commands/module-status.ts +31 -12
- 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/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/manifest/network-requirement-schema.test.ts +141 -0
- package/src/manifest/schema.ts +124 -0
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -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
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- package/src/variables/declarative-derivation.ts +13 -2
|
@@ -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
|
+
});
|