@celilo/cli 0.24.0 → 0.24.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/package.json +1 -1
- 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/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -0
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- package/src/variables/declarative-derivation.ts +13 -2
package/package.json
CHANGED
|
@@ -57,7 +57,12 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
57
57
|
const result = await handleModuleConfigSet(['testmod', 'vmid', '203']);
|
|
58
58
|
expect(result.success).toBe(false);
|
|
59
59
|
if (!result.success) {
|
|
60
|
-
|
|
60
|
+
// The refusal now names the source rather than a bespoke
|
|
61
|
+
// "infrastructure-managed" phrase, because `infrastructure` stopped being
|
|
62
|
+
// the only refused source — every non-`user` source is refused, and each
|
|
63
|
+
// gets guidance aimed at its own upstream.
|
|
64
|
+
expect(result.error).toContain('derived by celilo (source: infrastructure)');
|
|
65
|
+
expect(result.error).toContain('not operator-settable');
|
|
61
66
|
}
|
|
62
67
|
});
|
|
63
68
|
|
|
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getDb } from '../../db/client';
|
|
8
8
|
import { modules } from '../../db/schema';
|
|
9
|
+
import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
|
|
9
10
|
import {
|
|
10
11
|
HEALTH_CHECK_INTERVAL_CONFIG_KEY,
|
|
11
12
|
reconcileModuleWatchState,
|
|
@@ -20,6 +21,12 @@ import {
|
|
|
20
21
|
MONITOR_INTERVAL_FLOOR_MINUTES,
|
|
21
22
|
cadenceSchema,
|
|
22
23
|
} from '../../services/cadence';
|
|
24
|
+
import {
|
|
25
|
+
declaredVariables,
|
|
26
|
+
describeDerivedSource,
|
|
27
|
+
explainNotSettable,
|
|
28
|
+
isDerivedVariable,
|
|
29
|
+
} from '../../services/config-provenance';
|
|
23
30
|
import {
|
|
24
31
|
deleteModuleConfig,
|
|
25
32
|
formatConfigValue,
|
|
@@ -27,6 +34,7 @@ import {
|
|
|
27
34
|
getModuleConfigValue,
|
|
28
35
|
setModuleConfigValue,
|
|
29
36
|
} from '../../services/module-config';
|
|
37
|
+
import { readResolutionContext } from '../../variables/context';
|
|
30
38
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
31
39
|
import type { CommandResult } from '../types';
|
|
32
40
|
|
|
@@ -180,17 +188,14 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
180
188
|
const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
|
|
181
189
|
|
|
182
190
|
// Validate key against manifest
|
|
183
|
-
const manifest = module.manifestData as
|
|
184
|
-
const
|
|
185
|
-
| { owns?: Array<{ name: string; required?: boolean; default?: string; source?: string }> }
|
|
186
|
-
| undefined;
|
|
187
|
-
const declaredVars = variables?.owns || [];
|
|
191
|
+
const manifest = module.manifestData as ModuleManifest;
|
|
192
|
+
const declaredVars = manifest.variables?.owns ?? [];
|
|
188
193
|
|
|
189
194
|
// Check if key is declared in manifest
|
|
190
195
|
const declaredVar = declaredVars.find((v) => v.name === key);
|
|
191
196
|
if (!declaredVar && !isFrameworkKey) {
|
|
192
197
|
const settableKeys = declaredVars
|
|
193
|
-
.filter((v) => v
|
|
198
|
+
.filter((v) => !isDerivedVariable(v))
|
|
194
199
|
.map((v) => v.name)
|
|
195
200
|
.join(', ');
|
|
196
201
|
const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
|
|
@@ -200,15 +205,23 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
200
205
|
};
|
|
201
206
|
}
|
|
202
207
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
208
|
+
// Refuse EVERY derived source, not just `infrastructure`.
|
|
209
|
+
//
|
|
210
|
+
// ISS-0069 established the principle — refuse at SET time rather than
|
|
211
|
+
// accepting-then-silently-overriding at deploy — and then applied it to one
|
|
212
|
+
// source out of four. So `celilo module config set authentik auth_url …`
|
|
213
|
+
// (a `capability`-sourced value) reported success, wrote the row, and was
|
|
214
|
+
// discarded on the next deploy. A command that says "Set config for authentik"
|
|
215
|
+
// and changes nothing is worse than one that refuses.
|
|
216
|
+
//
|
|
217
|
+
// No counter-example survived review of a good reason to pin a derived value:
|
|
218
|
+
// a derived value computes the right answer from one source of truth, so if
|
|
219
|
+
// the answer is wrong the source is wrong, and fixing the source fixes every
|
|
220
|
+
// consumer at once while pinning one module hides the divergence.
|
|
221
|
+
if (declaredVar && isDerivedVariable(declaredVar)) {
|
|
209
222
|
return {
|
|
210
223
|
success: false,
|
|
211
|
-
error:
|
|
224
|
+
error: explainNotSettable(moduleId, declaredVar),
|
|
212
225
|
};
|
|
213
226
|
}
|
|
214
227
|
|
|
@@ -354,10 +367,24 @@ export async function handleModuleConfigGet(args: string[]): Promise<CommandResu
|
|
|
354
367
|
};
|
|
355
368
|
}
|
|
356
369
|
|
|
357
|
-
// Get all config for module
|
|
358
|
-
|
|
370
|
+
// Get all config for module, split by who owns each value.
|
|
371
|
+
//
|
|
372
|
+
// Printing every row flat presented a value celilo computed as if the
|
|
373
|
+
// operator had chosen it, which is how a derived value gets "corrected" by
|
|
374
|
+
// hand and silently reverted. The derived section is also read from the
|
|
375
|
+
// resolution context rather than the config rows, so it shows what celilo
|
|
376
|
+
// computes RIGHT NOW — including values that were never written down, the
|
|
377
|
+
// absence that caused the 2026-08-14 DNS outage.
|
|
378
|
+
const declared = declaredVariables(module.manifestData as ModuleManifest);
|
|
379
|
+
const stored = getAllModuleConfigValues(moduleId);
|
|
380
|
+
const userConfigs = stored.filter((c) => {
|
|
381
|
+
const variable = declared.get(c.key);
|
|
382
|
+
return !variable || !isDerivedVariable(variable);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const derivedValues = await resolveDerivedForDisplay(moduleId, declared, db);
|
|
359
386
|
|
|
360
|
-
if (
|
|
387
|
+
if (userConfigs.length === 0 && derivedValues.length === 0) {
|
|
361
388
|
return {
|
|
362
389
|
success: true,
|
|
363
390
|
message: `No configuration set for ${moduleId}`,
|
|
@@ -365,14 +392,70 @@ export async function handleModuleConfigGet(args: string[]): Promise<CommandResu
|
|
|
365
392
|
}
|
|
366
393
|
|
|
367
394
|
const lines = [`Configuration for ${moduleId}:`, ''];
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
395
|
+
if (userConfigs.length > 0) {
|
|
396
|
+
for (const config of userConfigs) {
|
|
397
|
+
lines.push(`${config.key} = ${formatConfigValue(config)}`);
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
lines.push('(nothing set by you)');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (derivedValues.length > 0) {
|
|
404
|
+
lines.push('', 'Derived by celilo (not settable — fix the source instead):', '');
|
|
405
|
+
for (const derived of derivedValues) {
|
|
406
|
+
lines.push(`${derived.key} = ${derived.value}`);
|
|
407
|
+
lines.push(` ${describeDerivedSource(derived.variable)}`);
|
|
408
|
+
}
|
|
371
409
|
}
|
|
372
410
|
|
|
373
411
|
return {
|
|
374
412
|
success: true,
|
|
375
413
|
message: lines.join('\n'),
|
|
376
|
-
data:
|
|
414
|
+
data: {
|
|
415
|
+
config: userConfigs.map((c) => ({ key: c.key, value: c.value })),
|
|
416
|
+
derived: derivedValues.map((d) => ({
|
|
417
|
+
key: d.key,
|
|
418
|
+
value: d.value,
|
|
419
|
+
source: d.variable.source,
|
|
420
|
+
})),
|
|
421
|
+
},
|
|
377
422
|
};
|
|
378
423
|
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The current value of each derived variable, for display only.
|
|
427
|
+
*
|
|
428
|
+
* Uses the side-effect-free resolution context: reading a config must not seed
|
|
429
|
+
* rows or allocate addresses. A module whose derives cannot resolve yet (an
|
|
430
|
+
* undeployed provider, an unset system key) reports nothing derived rather than
|
|
431
|
+
* failing the whole command — `get` is how an operator diagnoses that state, so
|
|
432
|
+
* it has to survive it.
|
|
433
|
+
*/
|
|
434
|
+
async function resolveDerivedForDisplay(
|
|
435
|
+
moduleId: string,
|
|
436
|
+
declared: Map<string, VariableDeclare>,
|
|
437
|
+
db: ReturnType<typeof getDb>,
|
|
438
|
+
): Promise<Array<{ key: string; value: string; variable: VariableDeclare }>> {
|
|
439
|
+
const derivedVars = [...declared.values()].filter(isDerivedVariable);
|
|
440
|
+
if (derivedVars.length === 0) return [];
|
|
441
|
+
|
|
442
|
+
let selfConfig: Record<string, string>;
|
|
443
|
+
try {
|
|
444
|
+
selfConfig = (await readResolutionContext(moduleId, db)).selfConfig;
|
|
445
|
+
} catch {
|
|
446
|
+
// Reported as "not computed yet" below rather than as an error, so the
|
|
447
|
+
// command still shows the operator their own config.
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const resolved: Array<{ key: string; value: string; variable: VariableDeclare }> = [];
|
|
452
|
+
for (const variable of derivedVars) {
|
|
453
|
+
const value = selfConfig[variable.name];
|
|
454
|
+
resolved.push({
|
|
455
|
+
key: variable.name,
|
|
456
|
+
value: value ?? '(not computed yet)',
|
|
457
|
+
variable,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return resolved;
|
|
461
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
effectiveBackupSchedule,
|
|
22
22
|
} from '../../services/backup-schedule';
|
|
23
23
|
import { formatCadence } from '../../services/cadence';
|
|
24
|
+
import { declaredVariables, isDerivedVariable } from '../../services/config-provenance';
|
|
24
25
|
import { getModuleSystems } from '../../services/deployed-systems';
|
|
25
26
|
import { configOverride, parseStoredConfigValue } from '../../services/module-config';
|
|
26
27
|
import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
|
|
@@ -210,22 +211,40 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
|
|
|
210
211
|
sections.push(placementLines.join('\n'));
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
// Section 2: Configuration
|
|
214
|
-
if
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
214
|
+
// Section 2: Configuration, split by who owns each value. A flat list
|
|
215
|
+
// presented a value celilo computed as if the operator had chosen it, which
|
|
216
|
+
// is how a derived value gets "corrected" by hand and silently reverted.
|
|
217
|
+
// `source` is the authority for the split, never the presence of a
|
|
218
|
+
// `derive_from` — see services/config-provenance.ts.
|
|
219
|
+
const declared = declaredVariables(module.manifestData as ModuleManifest);
|
|
220
|
+
const isDerivedKey = (key: string) => {
|
|
221
|
+
const variable = declared.get(key);
|
|
222
|
+
return variable !== undefined && isDerivedVariable(variable);
|
|
223
|
+
};
|
|
224
|
+
// `value` is the human-readable display form (e.g. "test-host" for a string,
|
|
225
|
+
// "2222" for a number, JSON-stringified for complex types) — populated by
|
|
226
|
+
// upsertModuleConfig alongside the canonical valueJson. Using it here keeps
|
|
227
|
+
// the status output free of JSON-quote noise around primitives.
|
|
228
|
+
const userConfigs = configs.filter((c) => !isDerivedKey(c.key));
|
|
229
|
+
const derivedConfigs = configs.filter((c) => isDerivedKey(c.key));
|
|
230
|
+
|
|
231
|
+
if (userConfigs.length > 0) {
|
|
232
|
+
sections.push(
|
|
233
|
+
['Configuration:', ...userConfigs.map((c) => ` ${c.key}: ${c.value}`)].join('\n'),
|
|
234
|
+
);
|
|
225
235
|
} else {
|
|
226
236
|
sections.push('Configuration: (none)');
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
if (derivedConfigs.length > 0) {
|
|
240
|
+
sections.push(
|
|
241
|
+
[
|
|
242
|
+
'Derived by celilo (not settable):',
|
|
243
|
+
...derivedConfigs.map((c) => ` ${c.key}: ${c.value} [${declared.get(c.key)?.source}]`),
|
|
244
|
+
].join('\n'),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
229
248
|
// Section 2b: Per-module policy — what celilo will DO to this module, and
|
|
230
249
|
// whether that came from the operator or from the module's author. A raw
|
|
231
250
|
// config key does not tell an operator what the manifest said, and an
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { eq } from 'drizzle-orm';
|
|
20
21
|
import type { DbClient } from '../db/client';
|
|
21
|
-
import { machines, moduleInfrastructure, modules } from '../db/schema';
|
|
22
|
+
import { machines, moduleConfigs, moduleInfrastructure, modules, systemConfig } from '../db/schema';
|
|
22
23
|
import { upsertModuleConfig } from '../services/module-config';
|
|
23
24
|
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
24
25
|
import { loadHookConfigMap } from './load-hook-config';
|
|
@@ -165,3 +166,170 @@ describe('loadHookConfigMap', () => {
|
|
|
165
166
|
expect(result['ip.primary']).toBeUndefined();
|
|
166
167
|
});
|
|
167
168
|
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The 2026-08-14 outage, as a test.
|
|
172
|
+
*
|
|
173
|
+
* `technitium` declares `vpn_subnet` with `source: system` and
|
|
174
|
+
* `derive_from: $system:network.control-plane-vpn.subnet`. The system key was
|
|
175
|
+
* set on the fleet AFTER the module had already been configured, so no
|
|
176
|
+
* `module_configs` row was ever written for it — `applyDeclarativeDerivations`
|
|
177
|
+
* skips a `source: system` variable once the module has any stored config for
|
|
178
|
+
* it, and nothing re-derives one that is absent. The variable is
|
|
179
|
+
* `required: false`, so deploy-validation passed without comment.
|
|
180
|
+
*
|
|
181
|
+
* The consumer was a capability factory, which gets its config from
|
|
182
|
+
* `loadHookConfigMap`. A direct `module_configs` select cannot see a value that
|
|
183
|
+
* was never stored, so the factory built split-horizon DNS with no view for the
|
|
184
|
+
* admin VPN: queries from it matched nothing, returned NOERROR with zero
|
|
185
|
+
* records, fell through to public DNS, and the operator could not reach the
|
|
186
|
+
* forge while every service reported healthy.
|
|
187
|
+
*
|
|
188
|
+
* The fix is that the hook config map is built from the resolution context —
|
|
189
|
+
* which recomputes derived values on every build — not from the table alone.
|
|
190
|
+
*/
|
|
191
|
+
describe('loadHookConfigMap: derived values the config table never stored', () => {
|
|
192
|
+
let db: DbClient;
|
|
193
|
+
|
|
194
|
+
const VPN_SUBNET_KEY = 'network.control-plane-vpn.subnet';
|
|
195
|
+
const VPN_SUBNET = '10.255.255.0/24';
|
|
196
|
+
|
|
197
|
+
beforeEach(async () => {
|
|
198
|
+
db = await setupTestDatabase();
|
|
199
|
+
db.insert(modules)
|
|
200
|
+
.values({
|
|
201
|
+
id: 'technitium',
|
|
202
|
+
name: 'Technitium DNS',
|
|
203
|
+
version: '1.0.0',
|
|
204
|
+
sourcePath: '/tmp/technitium',
|
|
205
|
+
manifestData: {
|
|
206
|
+
variables: {
|
|
207
|
+
owns: [
|
|
208
|
+
{ name: 'hostname', type: 'string', source: 'user' },
|
|
209
|
+
{
|
|
210
|
+
name: 'vpn_subnet',
|
|
211
|
+
type: 'string',
|
|
212
|
+
source: 'system',
|
|
213
|
+
required: false,
|
|
214
|
+
derive_from: `$system:${VPN_SUBNET_KEY}`,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
.run();
|
|
221
|
+
// The module was configured first: it has its user rows, and no row for
|
|
222
|
+
// the derived variable.
|
|
223
|
+
upsertModuleConfig(db, 'technitium', 'hostname', 'dns-int');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
afterEach(async () => {
|
|
227
|
+
await cleanupTestDatabase(db);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('a hook sees a source:system derive whose system key was set after the module was configured', async () => {
|
|
231
|
+
db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
|
|
232
|
+
|
|
233
|
+
const result = await loadHookConfigMap('technitium', db);
|
|
234
|
+
|
|
235
|
+
expect(result.hostname).toBe('dns-int');
|
|
236
|
+
expect(result.vpn_subnet).toBe(VPN_SUBNET);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('recomputing does not write the derived value back into module_configs', async () => {
|
|
240
|
+
db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
|
|
241
|
+
|
|
242
|
+
await loadHookConfigMap('technitium', db);
|
|
243
|
+
|
|
244
|
+
// Reading a hook's config is not a deploy. It must not seed or refresh
|
|
245
|
+
// stored config as a side effect — that is how a stale snapshot gets
|
|
246
|
+
// written in the first place.
|
|
247
|
+
const stored = db
|
|
248
|
+
.select()
|
|
249
|
+
.from(moduleConfigs)
|
|
250
|
+
.where(eq(moduleConfigs.moduleId, 'technitium'))
|
|
251
|
+
.all();
|
|
252
|
+
expect(stored.map((row) => row.key).sort()).toEqual(['hostname']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('an optional derive whose system key is still unset stays absent, not an error', async () => {
|
|
256
|
+
// No systemConfig row at all — the state the fleet was in before the VPN
|
|
257
|
+
// subnet was declared. `required: false`, so this is silence, not failure.
|
|
258
|
+
const result = await loadHookConfigMap('technitium', db);
|
|
259
|
+
|
|
260
|
+
expect(result.hostname).toBe('dns-int');
|
|
261
|
+
expect(result.vpn_subnet).toBeUndefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('an already-stored value reaches the hook unchanged', async () => {
|
|
265
|
+
// Precedence: a stored row wins, and the recomputed context only fills
|
|
266
|
+
// keys the table has no row for. Every derived value on the fleet today is
|
|
267
|
+
// stored, so this is what keeps the new read path from changing what any
|
|
268
|
+
// deployed hook already receives — the change is purely additive here.
|
|
269
|
+
// (Once derived values stop being persisted, the stored row disappears and
|
|
270
|
+
// the recomputed value is all that is left.)
|
|
271
|
+
db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
|
|
272
|
+
upsertModuleConfig(db, 'technitium', 'vpn_subnet', '10.99.0.0/24');
|
|
273
|
+
|
|
274
|
+
const result = await loadHookConfigMap('technitium', db);
|
|
275
|
+
|
|
276
|
+
expect(result.vpn_subnet).toBe('10.99.0.0/24');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Recomputation must not make a hook's config LESS available than reading the
|
|
282
|
+
* table did.
|
|
283
|
+
*
|
|
284
|
+
* `applyDeclarativeDerivations` throws when a `required: true` variable's
|
|
285
|
+
* derivation fails, which at generate time is exactly right — a deploy that
|
|
286
|
+
* cannot resolve a required value should stop. But this reader also serves
|
|
287
|
+
* health checks and `module run-hook`, which used to be unable to fail this
|
|
288
|
+
* way at all: they read stored rows, and a stored row cannot throw. A provider
|
|
289
|
+
* that is paused, removed, or not yet deployed would take its consumers'
|
|
290
|
+
* health checks down with it — reporting the consumer as broken when the
|
|
291
|
+
* consumer is fine.
|
|
292
|
+
*/
|
|
293
|
+
describe('loadHookConfigMap: a failing derive does not take the hook down', () => {
|
|
294
|
+
let db: DbClient;
|
|
295
|
+
|
|
296
|
+
beforeEach(async () => {
|
|
297
|
+
db = await setupTestDatabase();
|
|
298
|
+
db.insert(modules)
|
|
299
|
+
.values({
|
|
300
|
+
id: 'caddy',
|
|
301
|
+
name: 'Caddy',
|
|
302
|
+
version: '1.0.0',
|
|
303
|
+
sourcePath: '/tmp/caddy',
|
|
304
|
+
manifestData: {
|
|
305
|
+
variables: {
|
|
306
|
+
owns: [
|
|
307
|
+
{ name: 'hostname', type: 'string', source: 'user' },
|
|
308
|
+
{
|
|
309
|
+
name: 'primary_domain',
|
|
310
|
+
type: 'string',
|
|
311
|
+
source: 'capability',
|
|
312
|
+
required: true,
|
|
313
|
+
derive_from: '$capability:dns_registrar.zone.primary_domain',
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
.run();
|
|
320
|
+
upsertModuleConfig(db, 'caddy', 'hostname', 'caddy');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterEach(async () => {
|
|
324
|
+
await cleanupTestDatabase(db);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('stored config still reaches the hook when a required derive cannot resolve', async () => {
|
|
328
|
+
// No `dns_registrar` capability is registered — the provider is paused,
|
|
329
|
+
// removed, or has not been deployed yet.
|
|
330
|
+
const result = await loadHookConfigMap('caddy', db);
|
|
331
|
+
|
|
332
|
+
expect(result.hostname).toBe('caddy');
|
|
333
|
+
expect(result.primary_domain).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -11,30 +11,65 @@
|
|
|
11
11
|
* `capability-loader.ts:loadModuleConfig` had — same shape, different
|
|
12
12
|
* code, easy to miss.
|
|
13
13
|
*
|
|
14
|
-
* The shape:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
14
|
+
* The shape, in the order the layers are applied:
|
|
15
|
+
* 1. Every row from `module_configs`, parsed from `valueJson` via
|
|
16
|
+
* the shared `parseStoredConfigValue` helper. This preserves the
|
|
17
|
+
* types declared in each module's manifest: `number` reads as
|
|
18
|
+
* `number`, `boolean` as `boolean`, complex types as their
|
|
19
|
+
* parsed JSON shape. Pre-Defect-1, this path returned raw strings
|
|
20
|
+
* for primitives (because `valueJson` was NULL for them); that
|
|
21
|
+
* broke capability calls like `firewall.exposeService({ports:[...]})`
|
|
22
|
+
* that did a `typeof === 'number'` check downstream. Fixed in
|
|
23
|
+
* v2 by always populating valueJson on write.
|
|
24
|
+
* 2. If `target_ip` isn't in the row set, look up the deployment
|
|
25
|
+
* machine and inject both `target_ip` AND `ip.primary` from
|
|
26
|
+
* `machines.ipAddress`. Two keys because consumers historically used
|
|
27
|
+
* either name (e.g. caddy's `setup-network.ts` checks
|
|
28
|
+
* `target_ip || ip.primary`); fixing the drift means filling both.
|
|
29
|
+
* 3. For each variable the module's manifest declares, the value the
|
|
30
|
+
* resolution context currently computes — but only where the first two
|
|
31
|
+
* layers left that key unset. See below.
|
|
29
32
|
*
|
|
30
33
|
* Container deploys write `target_ip` into `module_configs` explicitly
|
|
31
34
|
* during generate/deploy, so the fallback only fires for machine
|
|
32
35
|
* deploys (existing iron, no terraform — what every e2e test uses).
|
|
36
|
+
*
|
|
37
|
+
* ## Why layer 3 exists
|
|
38
|
+
*
|
|
39
|
+
* A derived variable — one whose value comes from system config, a
|
|
40
|
+
* capability, or the selected infrastructure rather than from the operator —
|
|
41
|
+
* only reaches a hook through this map. Reading `module_configs` alone can
|
|
42
|
+
* only see the derives that happen to have been WRITTEN there, and one that
|
|
43
|
+
* was never written is indistinguishable from one that does not exist.
|
|
44
|
+
*
|
|
45
|
+
* On 2026-08-14 that was not a theoretical gap. `technitium`'s `vpn_subnet`
|
|
46
|
+
* (`source: system`) had no row, because the system key was set after the
|
|
47
|
+
* module was configured and nothing re-derives a `source: system` value that
|
|
48
|
+
* is already absent. Being `required: false`, deploy-validation passed in
|
|
49
|
+
* silence. The capability factory that builds split-horizon DNS therefore
|
|
50
|
+
* built no view for the admin VPN; queries from it matched nothing, returned
|
|
51
|
+
* NOERROR with zero records, fell through to public DNS, and the operator
|
|
52
|
+
* could not reach the forge while every service reported healthy.
|
|
53
|
+
*
|
|
54
|
+
* So the map is completed from `readResolutionContext` — the same computation
|
|
55
|
+
* a build does, run without any of a build's side effects. Stored rows still
|
|
56
|
+
* win, which is what makes this purely additive for every value already on
|
|
57
|
+
* the fleet; the context only supplies what the table is missing. Context
|
|
58
|
+
* values arrive as strings (declarative derivation resolves string templates
|
|
59
|
+
* only), so this layer never overwrites a typed row with a stringified one.
|
|
60
|
+
*
|
|
61
|
+
* It is narrowed to the manifest's declared variables on purpose. The
|
|
62
|
+
* resolution context also carries values that exist to drive template
|
|
63
|
+
* generation — `inventory.*`, `requires.system.*`, `lxc_nameserver` — which a
|
|
64
|
+
* hook has no business reading and which have never appeared in this map.
|
|
65
|
+
* Widening a hook's view of the world is not what this layer is for.
|
|
33
66
|
*/
|
|
34
67
|
import { eq } from 'drizzle-orm';
|
|
35
68
|
import type { DbClient } from '../db/client';
|
|
36
|
-
import { machines, moduleConfigs, moduleInfrastructure } from '../db/schema';
|
|
69
|
+
import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema';
|
|
70
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
37
71
|
import { parseStoredConfigValue } from '../services/module-config';
|
|
72
|
+
import { readResolutionContext } from '../variables/context';
|
|
38
73
|
|
|
39
74
|
export async function loadHookConfigMap(
|
|
40
75
|
moduleId: string,
|
|
@@ -51,19 +86,82 @@ export async function loadHookConfigMap(
|
|
|
51
86
|
configMap[c.key] = parseStoredConfigValue(c);
|
|
52
87
|
}
|
|
53
88
|
|
|
54
|
-
|
|
89
|
+
applyMachineAddressFallback(configMap, moduleId, db);
|
|
90
|
+
await applyRecomputedDerivedValues(configMap, moduleId, db);
|
|
91
|
+
|
|
92
|
+
return configMap;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fill in the declared variables the config rows have no value for, using the
|
|
97
|
+
* value the resolution context computes right now. Mutates `configMap`.
|
|
98
|
+
*/
|
|
99
|
+
async function applyRecomputedDerivedValues(
|
|
100
|
+
configMap: Record<string, unknown>,
|
|
101
|
+
moduleId: string,
|
|
102
|
+
db: DbClient,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
105
|
+
if (!module?.manifestData) return;
|
|
106
|
+
|
|
107
|
+
const declared = (module.manifestData as ModuleManifest).variables?.owns ?? [];
|
|
108
|
+
const missing = declared.filter((variable) => !(variable.name in configMap));
|
|
109
|
+
if (missing.length === 0) return;
|
|
110
|
+
|
|
111
|
+
let context: Awaited<ReturnType<typeof readResolutionContext>>;
|
|
112
|
+
try {
|
|
113
|
+
context = await readResolutionContext(moduleId, db);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Derivation THROWS when a `required: true` variable cannot be resolved —
|
|
116
|
+
// correct at generate time, where a deploy that cannot resolve a required
|
|
117
|
+
// value should stop. This reader also serves health checks and
|
|
118
|
+
// `module run-hook`, which previously could not fail this way at all: a
|
|
119
|
+
// stored row cannot throw. Letting it propagate would mean a provider that
|
|
120
|
+
// is paused, removed, or not yet deployed takes down the health checks of
|
|
121
|
+
// every module that derives from it, reporting healthy consumers as
|
|
122
|
+
// broken.
|
|
123
|
+
//
|
|
124
|
+
// So the hook falls back to what the table holds — exactly what it
|
|
125
|
+
// received before recomputation existed, never less. Logged rather than
|
|
126
|
+
// swallowed (Rule 6.2): a derive that cannot resolve is worth knowing
|
|
127
|
+
// about even when the hook survives it.
|
|
128
|
+
console.error(
|
|
129
|
+
`Could not recompute derived config for '${moduleId}'; the hook sees only its stored config. ` +
|
|
130
|
+
`Derived values (${missing.map((v) => v.name).join(', ')}) may be missing:`,
|
|
131
|
+
error,
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const variable of missing) {
|
|
137
|
+
const value = context.selfConfig[variable.name];
|
|
138
|
+
if (value !== undefined) {
|
|
139
|
+
configMap[variable.name] = value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fill `target_ip` / `ip.primary` from the deployment machine when the config
|
|
146
|
+
* rows carry no address of their own. Mutates `configMap` in place.
|
|
147
|
+
*/
|
|
148
|
+
function applyMachineAddressFallback(
|
|
149
|
+
configMap: Record<string, unknown>,
|
|
150
|
+
moduleId: string,
|
|
151
|
+
db: DbClient,
|
|
152
|
+
): void {
|
|
153
|
+
if (configMap.target_ip) return;
|
|
55
154
|
|
|
56
155
|
const infra = db
|
|
57
156
|
.select()
|
|
58
157
|
.from(moduleInfrastructure)
|
|
59
158
|
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
60
159
|
.get();
|
|
61
|
-
if (!infra?.machineId) return
|
|
160
|
+
if (!infra?.machineId) return;
|
|
62
161
|
|
|
63
162
|
const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
|
|
64
|
-
if (!machine) return
|
|
163
|
+
if (!machine) return;
|
|
65
164
|
|
|
66
165
|
configMap.target_ip = machine.ipAddress;
|
|
67
166
|
configMap['ip.primary'] = machine.ipAddress;
|
|
68
|
-
return configMap;
|
|
69
167
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a config value — the operator, or celilo.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing case here is the one that looks like a special case and is
|
|
5
|
+
* not: `derive_from` does NOT mean derived. Getting that backwards refuses an
|
|
6
|
+
* operator's edit to their own config, and a migration written on the same test
|
|
7
|
+
* would delete the row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import type { VariableDeclare } from '../manifest/schema';
|
|
12
|
+
import {
|
|
13
|
+
declaredVariables,
|
|
14
|
+
describeDerivedSource,
|
|
15
|
+
explainNotSettable,
|
|
16
|
+
isDerivedVariable,
|
|
17
|
+
} from './config-provenance';
|
|
18
|
+
|
|
19
|
+
function variable(overrides: Partial<VariableDeclare> & { name: string }): VariableDeclare {
|
|
20
|
+
return {
|
|
21
|
+
type: 'string',
|
|
22
|
+
required: false,
|
|
23
|
+
source: 'user',
|
|
24
|
+
...overrides,
|
|
25
|
+
} as VariableDeclare;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('isDerivedVariable', () => {
|
|
29
|
+
test('a user-sourced variable is the operator’s', () => {
|
|
30
|
+
expect(isDerivedVariable(variable({ name: 'acme_email', source: 'user' }))).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test.each(['capability', 'system', 'infrastructure', 'terraform'] as const)(
|
|
34
|
+
'a %s-sourced variable is celilo’s',
|
|
35
|
+
(source) => {
|
|
36
|
+
expect(isDerivedVariable(variable({ name: 'x', source }))).toBe(true);
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
test('a variable with NO declared source reads as the operator’s', () => {
|
|
41
|
+
// The manifest schema requires `source`, so this only happens for a
|
|
42
|
+
// malformed or pre-schema manifest already sitting in `manifest_data`. The
|
|
43
|
+
// question is which way to be wrong: guessing "derived" refuses an
|
|
44
|
+
// operator's `set` with a message insisting celilo owns a value nothing
|
|
45
|
+
// computes, which they cannot act on.
|
|
46
|
+
const noSource = {
|
|
47
|
+
name: 'app_port',
|
|
48
|
+
type: 'integer',
|
|
49
|
+
required: false,
|
|
50
|
+
} as unknown as VariableDeclare;
|
|
51
|
+
|
|
52
|
+
expect(isDerivedVariable(noSource)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a user-sourced variable WITH a derive_from is still the operator’s', () => {
|
|
56
|
+
// iptables: `firewall_ip`, `source: user`, `derive_from: $machine:ipAddress`.
|
|
57
|
+
// `$machine:` derives are answered by the config interview — they seed a
|
|
58
|
+
// default the operator confirms — so the row is operator config. Classing
|
|
59
|
+
// it as derived would refuse an operator correcting their own firewall
|
|
60
|
+
// address, and deleting it on the same test would blind the trusted-sources
|
|
61
|
+
// audit in services/firewall-reach.ts, which reads exactly this row.
|
|
62
|
+
const firewallIp = variable({
|
|
63
|
+
name: 'firewall_ip',
|
|
64
|
+
source: 'user',
|
|
65
|
+
required: true,
|
|
66
|
+
derive_from: '$machine:ipAddress',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(isDerivedVariable(firewallIp)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('declaredVariables', () => {
|
|
74
|
+
test('indexes a manifest’s owned variables by name', () => {
|
|
75
|
+
const declared = declaredVariables({
|
|
76
|
+
variables: {
|
|
77
|
+
owns: [variable({ name: 'hostname' }), variable({ name: 'vpn_subnet', source: 'system' })],
|
|
78
|
+
},
|
|
79
|
+
} as never);
|
|
80
|
+
|
|
81
|
+
expect([...declared.keys()].sort()).toEqual(['hostname', 'vpn_subnet']);
|
|
82
|
+
expect(declared.get('vpn_subnet')?.source).toBe('system');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a manifest declaring nothing yields an empty index, not a throw', () => {
|
|
86
|
+
expect(declaredVariables({} as never).size).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('explainNotSettable', () => {
|
|
91
|
+
test('a capability-sourced value points at the provider, not at this module', () => {
|
|
92
|
+
// The live footgun: `celilo module config set authentik auth_url …`
|
|
93
|
+
// reported success, wrote the row, and was discarded on the next deploy.
|
|
94
|
+
const message = explainNotSettable(
|
|
95
|
+
'authentik',
|
|
96
|
+
variable({
|
|
97
|
+
name: 'auth_url',
|
|
98
|
+
source: 'capability',
|
|
99
|
+
derive_from: '$capability:authentication.url',
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(message).toContain('not operator-settable');
|
|
104
|
+
expect(message).toContain('source: capability');
|
|
105
|
+
// Actionable: the only way to change a derived value is to fix its source.
|
|
106
|
+
expect(message).toContain('provider');
|
|
107
|
+
expect(message).toContain('redeploy');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a system-sourced value names the system key to set', () => {
|
|
111
|
+
const message = explainNotSettable(
|
|
112
|
+
'technitium',
|
|
113
|
+
variable({
|
|
114
|
+
name: 'vpn_subnet',
|
|
115
|
+
source: 'system',
|
|
116
|
+
derive_from: '$system:network.control-plane-vpn.subnet',
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// The operator should be able to copy the fix out of the error. The
|
|
121
|
+
// `$system:` prefix is stripped so the key is the one `system config set`
|
|
122
|
+
// actually takes.
|
|
123
|
+
expect(message).toContain('celilo system config set network.control-plane-vpn.subnet');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an infrastructure-sourced value keeps the placement guidance', () => {
|
|
127
|
+
const message = explainNotSettable(
|
|
128
|
+
'caddy',
|
|
129
|
+
variable({ name: 'vmid', source: 'infrastructure' }),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(message).toContain('IPAM');
|
|
133
|
+
expect(message).toContain('celilo proxmox migrate');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('describeDerivedSource', () => {
|
|
138
|
+
test('names the upstream, and the template when there is one', () => {
|
|
139
|
+
expect(
|
|
140
|
+
describeDerivedSource(
|
|
141
|
+
variable({
|
|
142
|
+
name: 'dmz_subnet',
|
|
143
|
+
source: 'system',
|
|
144
|
+
derive_from: '$system:network.dmz.subnet',
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
).toBe('from system config ($system:network.dmz.subnet)');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('degrades to the upstream alone when no template is declared', () => {
|
|
151
|
+
expect(describeDerivedSource(variable({ name: 'vmid', source: 'infrastructure' }))).toContain(
|
|
152
|
+
'infrastructure celilo selected',
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a module-config value: the operator, or celilo.
|
|
3
|
+
*
|
|
4
|
+
* A module's manifest declares a `source` for every variable it owns. `user`
|
|
5
|
+
* means the operator supplies it. Every other source — `capability`, `system`,
|
|
6
|
+
* `infrastructure`, `terraform` — means celilo computes it from somewhere else:
|
|
7
|
+
* another module's published capability data, system config, the selected
|
|
8
|
+
* infrastructure, a Terraform output.
|
|
9
|
+
*
|
|
10
|
+
* That distinction has to be shared rather than re-derived per command, because
|
|
11
|
+
* it was previously spelled differently in each place and the disagreements were
|
|
12
|
+
* silent. `module config set` refused only `source: infrastructure`, so setting
|
|
13
|
+
* a `capability`- or `system`-sourced value REPORTED SUCCESS, wrote the row, and
|
|
14
|
+
* was then discarded on the next deploy —
|
|
15
|
+
* `celilo module config set authentik auth_url …` being the live example. And
|
|
16
|
+
* `module config get` printed every row flat, so a value celilo derived was
|
|
17
|
+
* indistinguishable from one the operator had chosen.
|
|
18
|
+
*
|
|
19
|
+
* ## `derive_from` does not mean derived
|
|
20
|
+
*
|
|
21
|
+
* The tempting shortcut is "it has a `derive_from` template, so celilo computes
|
|
22
|
+
* it". That is wrong, and expensively so. `iptables` declares:
|
|
23
|
+
*
|
|
24
|
+
* - name: firewall_ip
|
|
25
|
+
* source: user
|
|
26
|
+
* derive_from: "$machine:ipAddress"
|
|
27
|
+
*
|
|
28
|
+
* `$machine:` derivations are answered by the config interview — they seed a
|
|
29
|
+
* default the operator confirms — not by template resolution. The row is
|
|
30
|
+
* operator config. Treating it as derived would refuse an operator's attempt to
|
|
31
|
+
* correct their own firewall address, and a migration that deleted rows on the
|
|
32
|
+
* same test would blind the trusted-sources audit that reads it.
|
|
33
|
+
*
|
|
34
|
+
* `source` is the authority. Nothing else is.
|
|
35
|
+
*/
|
|
36
|
+
import type { ModuleManifest, VariableDeclare } from '../manifest/schema';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does celilo compute this variable, rather than the operator supply it?
|
|
40
|
+
*
|
|
41
|
+
* An ABSENT source reads as the operator's. The manifest schema requires
|
|
42
|
+
* `source`, so absent means a malformed or pre-schema manifest sitting in
|
|
43
|
+
* `modules.manifest_data` — and for those the question is which way to be
|
|
44
|
+
* wrong. Guessing "derived" refuses an operator's attempt to set their own
|
|
45
|
+
* config with a message insisting celilo owns a value nothing computes, which
|
|
46
|
+
* is unanswerable. Guessing "user" preserves what celilo did before this
|
|
47
|
+
* predicate existed, when only `infrastructure` was refused.
|
|
48
|
+
*/
|
|
49
|
+
export function isDerivedVariable(variable: Pick<VariableDeclare, 'source'>): boolean {
|
|
50
|
+
return variable.source !== undefined && variable.source !== 'user';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every variable a module's manifest declares, indexed by name. */
|
|
54
|
+
export function declaredVariables(manifest: ModuleManifest): Map<string, VariableDeclare> {
|
|
55
|
+
return new Map((manifest.variables?.owns ?? []).map((variable) => [variable.name, variable]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A one-line explanation of where a derived value comes from, for output an
|
|
60
|
+
* operator reads. Says which upstream to go fix, since fixing the source is the
|
|
61
|
+
* only way to change a derived value.
|
|
62
|
+
*/
|
|
63
|
+
export function describeDerivedSource(variable: VariableDeclare): string {
|
|
64
|
+
switch (variable.source) {
|
|
65
|
+
case 'capability':
|
|
66
|
+
return variable.derive_from
|
|
67
|
+
? `from another module's capability data (${variable.derive_from})`
|
|
68
|
+
: "from another module's capability data";
|
|
69
|
+
case 'system':
|
|
70
|
+
return variable.derive_from
|
|
71
|
+
? `from system config (${variable.derive_from})`
|
|
72
|
+
: 'from system config';
|
|
73
|
+
case 'infrastructure':
|
|
74
|
+
return 'from the infrastructure celilo selected for this module';
|
|
75
|
+
case 'terraform':
|
|
76
|
+
return 'from a Terraform output, at deploy time';
|
|
77
|
+
default:
|
|
78
|
+
return `computed by celilo (source: ${variable.source})`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why `module config set` refuses this variable, and what to do instead.
|
|
84
|
+
* Actionable per source: a derived value is only wrong because its upstream is
|
|
85
|
+
* wrong, and fixing the upstream fixes every consumer at once, where pinning one
|
|
86
|
+
* module hides the divergence.
|
|
87
|
+
*/
|
|
88
|
+
export function explainNotSettable(moduleId: string, variable: VariableDeclare): string {
|
|
89
|
+
const header = `'${variable.name}' is derived by celilo (source: ${variable.source}) — not operator-settable.`;
|
|
90
|
+
const origin = `It is computed ${describeDerivedSource(variable)}, so a value set here would be overwritten the next time ${moduleId} is generated.`;
|
|
91
|
+
|
|
92
|
+
switch (variable.source) {
|
|
93
|
+
case 'capability':
|
|
94
|
+
return `${header}\n${origin}\n • Fix it at the provider: change the config of the module that publishes this capability, then redeploy it.\n • 'celilo module config get ${moduleId}' shows the value celilo currently computes.`;
|
|
95
|
+
case 'system':
|
|
96
|
+
return `${header}\n${origin}\n • Fix it at the source: 'celilo system config set ${variable.derive_from?.replace(/^\$\{?system:/, '').replace(/\}$/, '') ?? '<key>'} <value>'.\n • That corrects every module deriving from it at once, rather than pinning this one.`;
|
|
97
|
+
case 'infrastructure':
|
|
98
|
+
return `${header}\n${origin}\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate'.\n • vmid / IP: auto-allocated by IPAM.`;
|
|
99
|
+
case 'terraform':
|
|
100
|
+
return `${header}\n${origin}\n • It is read back from Terraform outputs after the deploy creates the resource.`;
|
|
101
|
+
default:
|
|
102
|
+
return `${header}\n${origin}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
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}')`);
|