@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +3 -1
- package/package.json +2 -2
- 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/cli/stdout-is-undecorated.test.ts +7 -1
- package/src/db/client.test.ts +63 -0
- package/src/db/client.ts +9 -2
- 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/templates/dns-ingress-ip.test.ts +123 -0
- package/src/templates/generator.ts +66 -43
- package/src/test-utils/bus-responder.ts +14 -1
|
@@ -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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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();
|