@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
package/src/cli/index.ts
CHANGED
|
@@ -119,6 +119,7 @@ import { handleSubscribersTest } from './commands/subscribers-test';
|
|
|
119
119
|
import { handleSystemApplyConfig } from './commands/system-apply-config';
|
|
120
120
|
import { handleSystemAudit } from './commands/system-audit';
|
|
121
121
|
import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
|
|
122
|
+
import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
|
|
122
123
|
import { handleSystemDoctor } from './commands/system-doctor';
|
|
123
124
|
import { handleSystemInit } from './commands/system-init';
|
|
124
125
|
import { handleSystemMigrate } from './commands/system-migrate';
|
|
@@ -1100,6 +1101,10 @@ Subcommands:
|
|
|
1100
1101
|
apply-config <key=val>... | --from-stdin
|
|
1101
1102
|
Headless config write — designed for module
|
|
1102
1103
|
hooks (no prompts, no guidance, just writes).
|
|
1104
|
+
Refuses network.* keys: networks are celilo's
|
|
1105
|
+
to define, not a module's.
|
|
1106
|
+
|
|
1107
|
+
discover-network Record the network this box sits on, from its own routing table (idempotent)
|
|
1103
1108
|
|
|
1104
1109
|
config set <key> <value> Set system-wide configuration value
|
|
1105
1110
|
config get [key] Get system configuration value(s)
|
|
@@ -2173,6 +2178,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2173
2178
|
return handleSystemApplyConfig(parsed.args, parsed.flags);
|
|
2174
2179
|
}
|
|
2175
2180
|
|
|
2181
|
+
if (parsed.subcommand === 'discover-network') {
|
|
2182
|
+
return handleSystemDiscoverNetwork();
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2176
2185
|
if (parsed.subcommand === 'config') {
|
|
2177
2186
|
const configSubcommand = parsed.args[0];
|
|
2178
2187
|
if (!configSubcommand) {
|
|
@@ -632,8 +632,8 @@ function buildCapabilityInterface(
|
|
|
632
632
|
undefined, // no upstream — the chain path handles that
|
|
633
633
|
undefined, // logger is applied by wrapWithLogging at the loader site
|
|
634
634
|
trustedSourceStore,
|
|
635
|
-
// LIVE
|
|
636
|
-
//
|
|
635
|
+
// LIVE — see FirewallZones.declaredNetworks for why this stays live
|
|
636
|
+
// even though nothing writes a network mid-run any more.
|
|
637
637
|
zones ? { list: zones.declaredNetworks } : undefined,
|
|
638
638
|
);
|
|
639
639
|
return stampProvider(iface, providerModuleId);
|
|
@@ -742,10 +742,28 @@ interface FirewallZones {
|
|
|
742
742
|
* subnet (design D1, amended).
|
|
743
743
|
*/
|
|
744
744
|
/**
|
|
745
|
-
* A LIVE read, not a snapshot
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
745
|
+
* A LIVE read, not a snapshot — and it STAYS live, deliberately, now that the
|
|
746
|
+
* race it was introduced for is gone.
|
|
747
|
+
*
|
|
748
|
+
* It was introduced because `wireguard` declared its VPN subnet and brought
|
|
749
|
+
* `wg0` up inside one hook run, so a converge later in that run had to see a
|
|
750
|
+
* value written after the capability was built. Modules no longer write
|
|
751
|
+
* networks at all (openspec/changes/networks-are-declared-not-written), and
|
|
752
|
+
* `ensureRequiredNetworks` defines every required one BEFORE the deploy loads
|
|
753
|
+
* any capability. So a snapshot would be correct today.
|
|
754
|
+
*
|
|
755
|
+
* It is kept live because being correct today is not the property worth having
|
|
756
|
+
* here. What a stale read costs is specific and severe: an interface celilo
|
|
757
|
+
* cannot attribute is ALIEN, and on a firewall with a recorded baseline an
|
|
758
|
+
* alien interface is disabled — which is how a converge came to shut down the
|
|
759
|
+
* admin VPN, the operator's way back into a remote box. A live read costs one
|
|
760
|
+
* query per converge. That is a trade worth making permanently, not one to
|
|
761
|
+
* re-evaluate whenever the write paths happen to look clean.
|
|
762
|
+
*
|
|
763
|
+
* And they are not entirely clean: `celilo system discover-network` writes
|
|
764
|
+
* `network.internal.*` from inside `celilo-mgmt`'s install hook. No firewall
|
|
765
|
+
* capability exists during that deploy, so it cannot bite — today. Liveness is
|
|
766
|
+
* what makes that sentence not need to stay true.
|
|
749
767
|
*/
|
|
750
768
|
declaredNetworks: () => Array<{ zone: string; subnet: string }>;
|
|
751
769
|
/**
|
|
@@ -851,14 +869,13 @@ export function loadTrustedSubnets(db: DbClient, firewallIp?: string): TrustedSu
|
|
|
851
869
|
* Every network celilo can attribute an interface to: one entry per
|
|
852
870
|
* `network.<name>.subnet` in system config.
|
|
853
871
|
*
|
|
854
|
-
* Read from the config rather than from `NETWORK_ZONES`, because celilo
|
|
855
|
-
* networks that are not placement zones
|
|
856
|
-
* `
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
* `
|
|
860
|
-
*
|
|
861
|
-
* ISOLATED it, shutting down the admin VPN.
|
|
872
|
+
* Read from the config rather than from `NETWORK_ZONES`, because celilo holds
|
|
873
|
+
* networks that are not placement zones — `network.control-plane-vpn.subnet`,
|
|
874
|
+
* which `wireguard` requires and reads. The firewall classifies `wg0` "by the
|
|
875
|
+
* same subnet containment it uses for every other leg", and reading only
|
|
876
|
+
* `NETWORK_ZONES` left that network invisible: the converge could not attribute
|
|
877
|
+
* `wg0` and refused — and on a firewall with a baseline it would have ISOLATED
|
|
878
|
+
* it, shutting down the admin VPN.
|
|
862
879
|
*
|
|
863
880
|
* `external` is deliberately absent even when something has set
|
|
864
881
|
* `network.external.subnet`: it is the RESIDUAL, decided by `isPubliclyRoutable`
|
|
@@ -1109,9 +1126,8 @@ async function buildFirewallChain(
|
|
|
1109
1126
|
currentUpstream,
|
|
1110
1127
|
logger,
|
|
1111
1128
|
trustedSourceStore,
|
|
1112
|
-
// LIVE
|
|
1113
|
-
//
|
|
1114
|
-
// visible to the converge that follows it in the same run.
|
|
1129
|
+
// LIVE — see FirewallZones.declaredNetworks. Cheap insurance against a
|
|
1130
|
+
// failure whose consequence is isolating the admin VPN.
|
|
1115
1131
|
{ list: zones.declaredNetworks },
|
|
1116
1132
|
);
|
|
1117
1133
|
|
|
@@ -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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `requires.networks` — a module NAMES the networks it depends on and never
|
|
3
|
+
* carries their values
|
|
4
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
5
|
+
*
|
|
6
|
+
* The rejection below is the schema-level half of "a module has no path to
|
|
7
|
+
* create a network definition". The other half is the write path
|
|
8
|
+
* ([[cli/commands/system-apply-config.ts]]).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { NetworkRequirementSchema, getRequiredNetworkNames } from './schema';
|
|
13
|
+
import type { ModuleManifest } from './schema';
|
|
14
|
+
|
|
15
|
+
describe('NetworkRequirementSchema', () => {
|
|
16
|
+
test('a requirement is just a name', () => {
|
|
17
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'control-plane-vpn' }).success).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('names are kebab-case, like every other user-facing identifier', () => {
|
|
21
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'control_plane_vpn' }).success).toBe(false);
|
|
22
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'DMZ' }).success).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a requirement carrying a subnet is rejected, and the message says why', () => {
|
|
26
|
+
const result = NetworkRequirementSchema.safeParse({
|
|
27
|
+
name: 'control-plane-vpn',
|
|
28
|
+
subnet: '10.255.255.0/24',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.success).toBe(false);
|
|
32
|
+
if (result.success) return;
|
|
33
|
+
const message = result.error.errors.map((e) => e.message).join(' ');
|
|
34
|
+
expect(message).toContain('celilo owns');
|
|
35
|
+
expect(message).toContain('$system:network.<name>.subnet');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a gateway or vlan on the requirement is rejected too', () => {
|
|
39
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'dmz', gateway: '10.0.10.1' }).success).toBe(
|
|
40
|
+
false,
|
|
41
|
+
);
|
|
42
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'dmz', vlan: 10 }).success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `from:` is the second form, for a module whose required set is decided per
|
|
47
|
+
* install rather than at authoring time — a firewall requires the networks it
|
|
48
|
+
* has legs on, and which legs it has is a property of the box it lands on.
|
|
49
|
+
* It still names networks and still carries no values.
|
|
50
|
+
*/
|
|
51
|
+
test('a requirement may name a config array instead of a literal', () => {
|
|
52
|
+
expect(NetworkRequirementSchema.safeParse({ from: '$self:zones' }).success).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("from must reference this module's own config, not another source", () => {
|
|
56
|
+
// `$machine:` / `$capability:` would make some other thing the author of the
|
|
57
|
+
// required set, which is the authority question this change exists to settle.
|
|
58
|
+
expect(NetworkRequirementSchema.safeParse({ from: '$machine:zones' }).success).toBe(false);
|
|
59
|
+
expect(NetworkRequirementSchema.safeParse({ from: '$capability:firewall.zones' }).success).toBe(
|
|
60
|
+
false,
|
|
61
|
+
);
|
|
62
|
+
expect(NetworkRequirementSchema.safeParse({ from: 'zones' }).success).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('exactly one of name or from — neither both nor neither', () => {
|
|
66
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'dmz', from: '$self:zones' }).success).toBe(
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
expect(NetworkRequirementSchema.safeParse({}).success).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('getRequiredNetworkNames', () => {
|
|
74
|
+
function manifestWith(
|
|
75
|
+
networks: Array<{ name?: string; from?: string }> | undefined,
|
|
76
|
+
): ModuleManifest {
|
|
77
|
+
return {
|
|
78
|
+
requires: { capabilities: [], networks },
|
|
79
|
+
} as unknown as ModuleManifest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('is empty for a module that requires no network', () => {
|
|
83
|
+
expect(getRequiredNetworkNames(manifestWith(undefined))).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('keeps declaration order and drops duplicates', () => {
|
|
87
|
+
expect(
|
|
88
|
+
getRequiredNetworkNames(manifestWith([{ name: 'dmz' }, { name: 'app' }, { name: 'dmz' }])),
|
|
89
|
+
).toEqual(['dmz', 'app']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("resolves a from: requirement against the module's own config", () => {
|
|
93
|
+
expect(
|
|
94
|
+
getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
|
|
95
|
+
zones: ['dmz', 'app', 'secure'],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual(['dmz', 'app', 'secure']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an array stored as JSON resolves too', () => {
|
|
101
|
+
// Array config arrives as either shape depending on how it was written, and
|
|
102
|
+
// a requirement that silently resolved to nothing would leave a firewall leg
|
|
103
|
+
// undeclared — which is how an interface ends up classified alien.
|
|
104
|
+
expect(
|
|
105
|
+
getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
|
|
106
|
+
zones: '["dmz","secure-mgmt"]',
|
|
107
|
+
}),
|
|
108
|
+
).toEqual(['dmz', 'secure-mgmt']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `external` is the RESIDUAL: an interface is external because it is publicly
|
|
113
|
+
* routable and matched no declared zone, never because it is contained in a
|
|
114
|
+
* subnet. Giving it one would reintroduce the overload that
|
|
115
|
+
* `readDeclaredNetworks` and `deployFirewall` both already exclude.
|
|
116
|
+
*/
|
|
117
|
+
test('external is dropped, however it is named', () => {
|
|
118
|
+
expect(
|
|
119
|
+
getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
|
|
120
|
+
zones: ['dmz', 'external'],
|
|
121
|
+
}),
|
|
122
|
+
).toEqual(['dmz']);
|
|
123
|
+
expect(getRequiredNetworkNames(manifestWith([{ name: 'external' }]))).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an unanswered config array requires nothing rather than throwing', () => {
|
|
127
|
+
// The firewall's zones are answered by the config interview, which runs
|
|
128
|
+
// before this. An empty result here means the interview has not happened
|
|
129
|
+
// yet, not that the module needs no networks.
|
|
130
|
+
expect(getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {})).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('literal and dynamic requirements combine', () => {
|
|
134
|
+
expect(
|
|
135
|
+
getRequiredNetworkNames(
|
|
136
|
+
manifestWith([{ name: 'control-plane-vpn' }, { from: '$self:zones' }]),
|
|
137
|
+
{ zones: ['dmz'] },
|
|
138
|
+
),
|
|
139
|
+
).toEqual(['control-plane-vpn', 'dmz']);
|
|
140
|
+
});
|
|
141
|
+
});
|