@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,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();
|
package/src/variables/context.ts
CHANGED
|
@@ -185,9 +185,14 @@ function resolveSelfRefsInObject(
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
* Build resolution context for a module
|
|
188
|
+
* Build the resolution context for a module, provisioning as it goes.
|
|
189
189
|
*
|
|
190
|
-
* Execution function (Rule 10.1) - performs database queries
|
|
190
|
+
* Execution function (Rule 10.1) - performs database queries AND writes:
|
|
191
|
+
* it seeds `module_configs` with defaults and zone-derived networking, and
|
|
192
|
+
* records the module's deployed system (allocating IPAM addresses when the
|
|
193
|
+
* host is a celilo-provisioned container). This is the generate/deploy-time
|
|
194
|
+
* entrypoint. Anything that only wants to READ the resolved configuration
|
|
195
|
+
* wants {@link readResolutionContext} instead.
|
|
191
196
|
*
|
|
192
197
|
* @param moduleId - Module to build context for
|
|
193
198
|
* @param db - Database client (optional, for testing)
|
|
@@ -197,6 +202,55 @@ export async function buildResolutionContext(
|
|
|
197
202
|
moduleId: string,
|
|
198
203
|
db = getDb(),
|
|
199
204
|
): Promise<ResolutionContext> {
|
|
205
|
+
return assembleResolutionContext(moduleId, db, { provision: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The same resolved configuration, computed without touching the database.
|
|
210
|
+
*
|
|
211
|
+
* Every derived value is recomputed from its current upstream, exactly as a
|
|
212
|
+
* build would compute it, but nothing is written: no config rows are seeded,
|
|
213
|
+
* no addresses are allocated, no deployed system is recorded. That makes it
|
|
214
|
+
* safe to call from read paths that run on a cadence — health checks, hook
|
|
215
|
+
* invocations, capability factories — where a provisioning side effect would
|
|
216
|
+
* be both surprising and, in the IPAM case, harmful.
|
|
217
|
+
*
|
|
218
|
+
* This is what {@link import('../hooks/load-hook-config').loadHookConfigMap}
|
|
219
|
+
* uses so a hook sees the value a derive currently produces rather than only
|
|
220
|
+
* the ones that happen to have been stored.
|
|
221
|
+
*
|
|
222
|
+
* @param moduleId - Module to build context for
|
|
223
|
+
* @param db - Database client (optional, for testing)
|
|
224
|
+
* @returns Resolution context with all data sources
|
|
225
|
+
*/
|
|
226
|
+
export async function readResolutionContext(
|
|
227
|
+
moduleId: string,
|
|
228
|
+
db = getDb(),
|
|
229
|
+
): Promise<ResolutionContext> {
|
|
230
|
+
return assembleResolutionContext(moduleId, db, { provision: false });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The shared body of both entrypoints. `provision` is deliberately private
|
|
235
|
+
* (Rule 10.3): callers choose a named function, not a flag.
|
|
236
|
+
*/
|
|
237
|
+
async function assembleResolutionContext(
|
|
238
|
+
moduleId: string,
|
|
239
|
+
db: DbClient,
|
|
240
|
+
{ provision }: { provision: boolean },
|
|
241
|
+
): Promise<ResolutionContext> {
|
|
242
|
+
/**
|
|
243
|
+
* Seed a config row — a no-op when only reading. Every seeded value is also
|
|
244
|
+
* assigned into `selfConfig` by the caller, so the resolved context is the
|
|
245
|
+
* same either way; what differs is whether it is written down.
|
|
246
|
+
*/
|
|
247
|
+
const persistConfig = (
|
|
248
|
+
key: string,
|
|
249
|
+
value: string | number | boolean | unknown[] | Record<string, unknown>,
|
|
250
|
+
): void => {
|
|
251
|
+
if (provision) upsertModuleConfig(db, moduleId, key, value);
|
|
252
|
+
};
|
|
253
|
+
|
|
200
254
|
// Fetch module manifest for VM resources
|
|
201
255
|
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
202
256
|
|
|
@@ -226,12 +280,12 @@ export async function buildResolutionContext(
|
|
|
226
280
|
|
|
227
281
|
// Store assigned values in module config
|
|
228
282
|
if (assigned.hostname) {
|
|
229
|
-
|
|
283
|
+
persistConfig('hostname', assigned.hostname);
|
|
230
284
|
selfConfig.hostname = assigned.hostname;
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
if (assigned.zone) {
|
|
234
|
-
|
|
288
|
+
persistConfig('zone', assigned.zone);
|
|
235
289
|
selfConfig.zone = assigned.zone;
|
|
236
290
|
}
|
|
237
291
|
}
|
|
@@ -248,9 +302,7 @@ export async function buildResolutionContext(
|
|
|
248
302
|
// declared shape — e.g. `default: 2222` (YAML int) round-trips as
|
|
249
303
|
// `number` not the string "2222". This is the root of Defect 1.
|
|
250
304
|
if (variable.default !== undefined && !selfConfig[variable.name]) {
|
|
251
|
-
|
|
252
|
-
db,
|
|
253
|
-
moduleId,
|
|
305
|
+
persistConfig(
|
|
254
306
|
variable.name,
|
|
255
307
|
variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
|
|
256
308
|
);
|
|
@@ -303,7 +355,7 @@ export async function buildResolutionContext(
|
|
|
303
355
|
for (const { manifestKey, configKey, systemValue } of resourceMappings) {
|
|
304
356
|
if (systemValue != null) {
|
|
305
357
|
// Canonical system size — always wins so a resize propagates.
|
|
306
|
-
|
|
358
|
+
persistConfig(configKey, systemValue);
|
|
307
359
|
selfConfig[configKey] = String(systemValue);
|
|
308
360
|
continue;
|
|
309
361
|
}
|
|
@@ -312,9 +364,7 @@ export async function buildResolutionContext(
|
|
|
312
364
|
// Pass them through unstringified so valueJson preserves the
|
|
313
365
|
// shape — see comment in the variable-defaults block above.
|
|
314
366
|
if (value !== undefined && !selfConfig[configKey]) {
|
|
315
|
-
|
|
316
|
-
db,
|
|
317
|
-
moduleId,
|
|
367
|
+
persistConfig(
|
|
318
368
|
configKey,
|
|
319
369
|
value as string | number | boolean | unknown[] | Record<string, unknown>,
|
|
320
370
|
);
|
|
@@ -333,7 +383,11 @@ export async function buildResolutionContext(
|
|
|
333
383
|
// outputs (resolveInfrastructureVariables), not here.
|
|
334
384
|
// This is the single place generate-time addresses are recorded — `target_ip`
|
|
335
385
|
// no longer lives in module_configs.
|
|
336
|
-
|
|
386
|
+
//
|
|
387
|
+
// Provisioning only. A read must never reach this: allocating an address is
|
|
388
|
+
// not something looking at a config should do, and by the time any hook runs
|
|
389
|
+
// the row is already there for `buildInfraSystemsMap` below to read.
|
|
390
|
+
if (provision && module?.manifestData) {
|
|
337
391
|
const manifest = module.manifestData as ModuleManifest;
|
|
338
392
|
const declared = getDeclaredSystems(manifest);
|
|
339
393
|
const hostname = selfConfig.hostname;
|
|
@@ -593,7 +647,7 @@ export async function buildResolutionContext(
|
|
|
593
647
|
|
|
594
648
|
// If zone from manifest but not in selfConfig, store it as first-class config
|
|
595
649
|
if (zone && !selfConfig.zone) {
|
|
596
|
-
|
|
650
|
+
persistConfig('zone', zone);
|
|
597
651
|
selfConfig.zone = zone;
|
|
598
652
|
}
|
|
599
653
|
|
|
@@ -625,7 +679,7 @@ export async function buildResolutionContext(
|
|
|
625
679
|
}
|
|
626
680
|
return value;
|
|
627
681
|
})();
|
|
628
|
-
|
|
682
|
+
persistConfig(field, coerced);
|
|
629
683
|
selfConfig[field] = String(coerced);
|
|
630
684
|
}
|
|
631
685
|
}
|
|
@@ -685,7 +739,7 @@ export async function buildResolutionContext(
|
|
|
685
739
|
}
|
|
686
740
|
|
|
687
741
|
if (isNew || isChanged) {
|
|
688
|
-
|
|
742
|
+
persistConfig(key, value);
|
|
689
743
|
selfConfig[key] = value;
|
|
690
744
|
}
|
|
691
745
|
}
|
|
@@ -52,6 +52,59 @@ describe('resolveDeclarativeDerivation', () => {
|
|
|
52
52
|
expect(result).toBe('10.0.10.0/24');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('resolves a system key containing hyphens', () => {
|
|
56
|
+
// celilo's zone names are kebab-case, so shipped manifests contain
|
|
57
|
+
// `$system:network.control-plane-vpn.subnet` and
|
|
58
|
+
// `$system:network.secure-mgmt.subnet`. While the match stopped at the
|
|
59
|
+
// first hyphen these looked up `network.control` / `network.secure`,
|
|
60
|
+
// threw, and — being optional — resolved to nothing in silence. That is
|
|
61
|
+
// half of the 2026-08-14 DNS outage.
|
|
62
|
+
const variable: VariableDeclare = {
|
|
63
|
+
name: 'vpn_subnet',
|
|
64
|
+
type: 'string',
|
|
65
|
+
required: false,
|
|
66
|
+
source: 'system',
|
|
67
|
+
derive_from: '$system:network.control-plane-vpn.subnet',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const context: ResolutionContext = {
|
|
71
|
+
moduleId: 'technitium',
|
|
72
|
+
selfConfig: {},
|
|
73
|
+
systemConfig: { 'network.control-plane-vpn.subnet': '10.255.255.0/24' },
|
|
74
|
+
systemSecrets: {},
|
|
75
|
+
secrets: {},
|
|
76
|
+
capabilities: {},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
expect(resolveDeclarativeDerivation(variable, context)).toBe('10.255.255.0/24');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('names the whole hyphenated key when it is missing', () => {
|
|
83
|
+
// The message has to name the key the manifest asked for. Reporting
|
|
84
|
+
// `network.secure` for a manifest that says `network.secure-mgmt` sends
|
|
85
|
+
// the reader looking for a key that was never requested.
|
|
86
|
+
const variable: VariableDeclare = {
|
|
87
|
+
name: 'secure_mgmt_subnet',
|
|
88
|
+
type: 'string',
|
|
89
|
+
required: true,
|
|
90
|
+
source: 'system',
|
|
91
|
+
derive_from: '$system:network.secure-mgmt.subnet',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const context: ResolutionContext = {
|
|
95
|
+
moduleId: 'technitium',
|
|
96
|
+
selfConfig: {},
|
|
97
|
+
systemConfig: {},
|
|
98
|
+
systemSecrets: {},
|
|
99
|
+
secrets: {},
|
|
100
|
+
capabilities: {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
expect(() => resolveDeclarativeDerivation(variable, context)).toThrow(
|
|
104
|
+
"Missing system config: network.secure-mgmt.subnet (required by variable 'secure_mgmt_subnet')",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
55
108
|
test('throws on missing system config', () => {
|
|
56
109
|
const variable: VariableDeclare = {
|
|
57
110
|
name: 'primary_domain',
|
|
@@ -56,8 +56,19 @@ function substituteVariables(
|
|
|
56
56
|
): string {
|
|
57
57
|
let result = input;
|
|
58
58
|
|
|
59
|
-
// Replace $system:key patterns (both $system:key and ${system:key} forms)
|
|
60
|
-
|
|
59
|
+
// Replace $system:key patterns (both $system:key and ${system:key} forms).
|
|
60
|
+
//
|
|
61
|
+
// The key may contain hyphens. celilo's own zone names are kebab-case, so
|
|
62
|
+
// `$system:network.control-plane-vpn.subnet` and
|
|
63
|
+
// `$system:network.secure-mgmt.subnet` are both real keys in shipped
|
|
64
|
+
// manifests — and neither could ever resolve while this class excluded `-`:
|
|
65
|
+
// the match stopped at the first hyphen, looked up `network.control`, and
|
|
66
|
+
// threw. For an optional variable that throw is swallowed, so the derive
|
|
67
|
+
// simply produced nothing, forever, in silence. That is half of the
|
|
68
|
+
// 2026-08-14 DNS outage (`technitium.vpn_subnet`); the other half is that
|
|
69
|
+
// nothing re-derived the value at read time — see
|
|
70
|
+
// `hooks/load-hook-config.ts`.
|
|
71
|
+
result = result.replace(/\$\{?system:([a-zA-Z0-9_.-]+)\}?/g, (_match, key) => {
|
|
61
72
|
const value = context.systemConfig[key];
|
|
62
73
|
if (value === undefined) {
|
|
63
74
|
throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`);
|