@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.
@@ -23,11 +23,11 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
23
23
 
24
24
  - **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS + DHCP address-pool bounds via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**. Two device behaviours shape this driver and are not shared by **greenwave** (celilo#739): the router **regenerates** `Pool.1.DNSServers` from its own upstream resolver list every ~15 min and on any config commit, so `setDhcpDns` also writes `Device.DNS.X_AXON_CustomServer` — the *input* to that computation, which survives — and **technitium re-asserts** the pool value on `timer.tick.1m`. Optional `dhcp_pool_start`/`dhcp_pool_end` bound the leased range; set them below the addresses IPAM allocates, because **IPAM has no knowledge of the router's DHCP pool** and nothing otherwise stops the router leasing an address celilo already assigned.
25
25
  - **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
26
- - **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
26
+ - **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **REQUIRES the networks it has legs on and defines none of them** (`requires.networks: [{from: $self:zones}]`) — the required set is resolved from the same `zones` value that says which NICs the box has, so the legs and the declared networks are ONE list. They used to be two: `zones` and a `provided_networks` array this module wrote into system config from its install hook, with nothing forcing them to agree. A leg whose network has no declared subnet classifies `alien`, and on a firewall with a recorded baseline an alien interface is DISABLED — so under-declaring was never cosmetic. `firewall-interface-classification` §3/D8 reached that conclusion for the test harness; `networks-are-declared-not-written` applies it in production and deletes `provided_networks` outright (breaking: iptables 3.0.0). celilo supplies each range, asking the operator when it holds none. **provides:** `firewall`.
27
27
  - **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
28
28
  - **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
29
29
  - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
30
- - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **Declares `network.control-plane-vpn.subnet` BEFORE it creates the interface** the first side effect of `on_install`, before any command touches the box. The ordering is load-bearing, not tidiness: the firewall classifies every interface on its host and one no declaration accounts for is `alien`, so creating `wg0` first would leave a window in which a converge a timer, the registry-poll CD, any unrelated module deploy could shut the admin VPN down as an intruder's interface. The module declares the NETWORK, never the device: only the firewall decides what an interface is for. `health_check` asserts the declaration still holds, because the other checks all pass while it is missing. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
30
+ - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
31
31
 
32
32
  ## Public edge (ingress / identity)
33
33
 
@@ -27,7 +27,9 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
27
27
  - **Infrastructure selection (container-service vs machine pool)** — `apps/celilo/src/services/machine-pool.ts` (`getMachineByHostname`, `addMachine`, `assignModuleToMachine`) and `apps/celilo/src/services/container-service.ts` (`getContainerServiceByName`, `addContainerService`, `verifyContainerService`). Provider API clients: `apps/celilo/src/api-clients/proxmox.ts`, `apps/celilo/src/api-clients/digitalocean.ts`.
28
28
  - **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table and returns `NetworkZone | 'unknown'`. It answers CONTAINMENT ONLY. It used to return `'external'` on no-match, which conflated "no declared subnet contains this" with "the internet can route to this" — on a firewall with five RFC1918 legs that reported four of them as facing the internet. `'unknown'` is the honest answer; the caller resolves it (see `machine add`: publicly routable → `external`, otherwise fail asking for `--zone`). The subnet-backed zone list is derived from `NETWORK_ZONES` minus `external`, which has no subnet and must never be given one.
29
29
  - **Interface classification** — `packages/capabilities/src/interface-classification.ts` — THE shared classifier, used by the backend and every firewall provider module so the two cannot drift apart again. `isPubliclyRoutable(ip)` is a property of the address alone (false for RFC 1918, RFC 6598 carrier-grade NAT, loopback, link-local, multicast, reserved). `classifyInterfaces(interfaces, zones)` assigns each interface `zone → external → alien`, first match winning, where `external` is the RESIDUAL — routable and claimed by no declared zone — and is never subnet-matched. `externalEdge()` returns none/single/**ambiguous** rather than silently picking the first public address. `defaultRouteFinding()` enforces the invariant that the default route leaves through `internal` or `external`. **`subnetContains(cidr, ip)` lives here and is the ONLY implementation** — three existed and disagreed (the backend's mishandled `/0`); the other two are deleted, not aliased. Design: `openspec/changes/firewall-interface-classification/design.md`.
30
- - **Declared networks (classification input)** — `readDeclaredNetworks(db)` in `apps/celilo/src/hooks/capability-loader.ts` — every `network.<name>.subnet` in system config, which is what an interface is attributed against. Read from the CONFIG, not from `NETWORK_ZONES`: celilo declares networks that are not placement zones (`network.control-plane-vpn.subnet`, written by `wireguard` before it brings `wg0` up). Injected into the firewall capability as a LIVE reader (`declaredNetworks`), alongside the trusted-source store and for the same reason a declaration written during a hook run must be visible to the converge that follows it in that same run, which a snapshot taken at capability-build time cannot be.
30
+ - **Declared networks (classification input)** — `readDeclaredNetworks(db)` in `apps/celilo/src/hooks/capability-loader.ts` — every `network.<name>.subnet` in system config, which is what an interface is attributed against. Read from the CONFIG, not from `NETWORK_ZONES`: celilo holds networks that are not placement zones (`network.control-plane-vpn.subnet`, which `wireguard` requires and reads). Injected into the firewall capability as a LIVE reader (`declaredNetworks`) that liveness was a mitigation for values written mid-run by a module hook, which `network-declaration` removes; see that spec before assuming a snapshot is still unsafe.
31
+ - **Network requirement + ensure (celilo owns the namespace)** — `apps/celilo/src/services/network-ensure.ts` (`ensureRequiredNetworks`), `NetworkRequirementSchema` / `getRequiredNetworkNames` in `apps/celilo/src/manifest/schema.ts`. A module declares `requires.networks: [{name}]` — a NAME, never a value; the schema is `.strict()` so a `subnet:` on the requirement is rejected with a message saying why. The deploy calls `ensureRequiredNetworks` in its interview phase, BEFORE generation and before any hook, and asks over the generic bus interview (`askText`, so it is answerable headless) for anything undefined. Which attributes a network has is celilo's answer, taken from `apps/celilo/schemas/system_config.json`: that file declares `network.<n>.gateway` for the routed segments and omits it for `control-plane-vpn`, so a gateway is never asked for a network that has none. Well-known names carry a `suggested` range there — deliberately NOT `default`, which `getDefaultConfiguration()` would seed at `system init`. Spec: `openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md`.
32
+ - **The network write path (closed) + celilo's own discovery** — `celilo system apply-config` (`apps/celilo/src/cli/commands/system-apply-config.ts`) REFUSES the whole `network.` namespace, `network.bridge` excepted (a Proxmox bridge name is not addressing, and it is the one network key with a schema default). That is the automation surface a module hook shells out to, so closing it there is what makes "networks are celilo's" an authority rather than a convention every module has to remember. The refusal names the alternative — declare the network, read it with `$system:` — because a bare rejection sends a module author hunting for a typo. The one write that legitimately needed the surface moved INTO celilo: `celilo system discover-network` (`apps/celilo/src/services/network-discovery.ts`, `cli/commands/system-discover-network.ts`) parses the box's own `ip route` and records `network.internal.*` — or `network.secure-mgmt.*` when the box is off the internal LAN (#300). `celilo-mgmt` calls it and decides nothing; it used to parse and write this itself. Idempotent and never overwrites addressing already set. Recurrence gate: `test-integration/module/no-module-writes-networks.test.ts`.
31
33
  - **Firewall interface audit** — `apps/celilo/src/services/audit/interface-classification.ts` — reports per-firewall classification in `celilo audit`: alien interfaces by name and address (drift), and the blocking findings a converge refuses on — a carrier-grade NAT leg, an ambiguous external edge, a default route on the wrong leg.
32
34
  - **Zone taxonomy (canonical list)** — `apps/celilo/src/db/schema.ts` — `NETWORK_ZONES` is the single array; `NetworkZone` is DERIVED from it. Never hand-maintain a second copy: a duplicate that dropped a member made zone validation return null and silently fall back to a wrong-but-valid zone.
33
35
  - **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "network.dmz.subnet": {
14
14
  "type": "string",
15
15
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
16
- "description": "DMZ subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone; see openspec/specs/progressive-zone-disclosure/spec.md)"
16
+ "description": "DMZ subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone; see openspec/specs/progressive-zone-disclosure/spec.md)",
17
+ "suggested": "10.0.10.0/24"
17
18
  },
18
19
  "network.dmz.gateway": {
19
20
  "type": "string",
@@ -29,7 +30,8 @@
29
30
  "network.app.subnet": {
30
31
  "type": "string",
31
32
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
32
- "description": "App subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)"
33
+ "description": "App subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)",
34
+ "suggested": "10.0.20.0/24"
33
35
  },
34
36
  "network.app.gateway": {
35
37
  "type": "string",
@@ -45,7 +47,8 @@
45
47
  "network.secure.subnet": {
46
48
  "type": "string",
47
49
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
48
- "description": "Secure subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)"
50
+ "description": "Secure subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)",
51
+ "suggested": "10.0.30.0/24"
49
52
  },
50
53
  "network.secure.gateway": {
51
54
  "type": "string",
@@ -77,12 +80,14 @@
77
80
  "network.control-plane-vpn.subnet": {
78
81
  "type": "string",
79
82
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
80
- "description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon \u2192 in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)"
83
+ "description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon \u2192 in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)",
84
+ "suggested": "10.255.255.0/24"
81
85
  },
82
86
  "network.secure-mgmt.subnet": {
83
87
  "type": "string",
84
88
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
85
- "description": "Control-plane subnet CIDR \u2014 the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted \u2014 discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic."
89
+ "description": "Control-plane subnet CIDR \u2014 the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted \u2014 discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic.",
90
+ "suggested": "10.0.120.0/24"
86
91
  },
87
92
  "network.secure-mgmt.gateway": {
88
93
  "type": "string",
@@ -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
- expect(result.error).toContain('infrastructure-managed');
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 Record<string, unknown>;
184
- const variables = manifest.variables as
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.source !== 'infrastructure')
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
- // ISS-0069: reject infrastructure-managed keys at SET time rather than
204
- // accepting-then-silently-overriding them at deploy. `source: infrastructure`
205
- // variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
206
- // derived by the deploy (IPAM allocates vmid/IP; the container service supplies
207
- // node/template/gateway/vlan), so a value set here would be ignored.
208
- if (declaredVar?.source === 'infrastructure') {
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: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
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
- const configs = getAllModuleConfigValues(moduleId);
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 (configs.length === 0) {
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
- for (const config of configs) {
369
- const formatted = formatConfigValue(config);
370
- lines.push(`${config.key} = ${formatted}`);
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: configs.map((c) => ({ key: c.key, value: c.value })),
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 (configs.length > 0) {
215
- const configLines = ['Configuration:'];
216
- for (const config of configs) {
217
- // `value` is the human-readable display form (e.g. "test-host"
218
- // for a string, "2222" for a number, JSON-stringified for
219
- // complex types) populated by upsertModuleConfig alongside
220
- // the canonical valueJson. Using it here keeps the status
221
- // output free of JSON-quote noise around primitives.
222
- configLines.push(` ${config.key}: ${config.value}`);
223
- }
224
- sections.push(configLines.join('\n'));
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
@@ -10,6 +10,18 @@
10
10
  * Catches the bug class where a future change to system-init (e.g.
11
11
  * adding a side-effect or gateway computation) drifts away from
12
12
  * what apply-config writes.
13
+ *
14
+ * ── One deliberate DIVERGENCE, added by `network-declaration` ──
15
+ *
16
+ * The two surfaces are no longer equivalent for `network.*` keys, and that is
17
+ * the point rather than drift. `system init` is the OPERATOR's surface and may
18
+ * still set addressing; `apply-config` is the AUTOMATION surface a module hook
19
+ * shells out to, and networks are celilo's to define, not a module's
20
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
21
+ * So the equivalence below is asserted over everything EXCEPT addressing, and a
22
+ * separate test asserts the refusal — because "these two agree" and "this one
23
+ * refuses" are both properties worth keeping, and collapsing them would lose
24
+ * whichever was written second.
13
25
  */
14
26
 
15
27
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
@@ -40,11 +52,9 @@ describe('system apply-config equivalence with system init --accept-defaults', (
40
52
  });
41
53
 
42
54
  test('apply-config writes the same systemConfig keys as system init --accept-defaults', async () => {
55
+ // No `network.*` here: apply-config refuses those now, deliberately.
56
+ // Everything else must still land identically through both paths.
43
57
  const overrides = [
44
- 'network.dmz.subnet=10.99.10.0/24',
45
- 'network.app.subnet=10.99.20.0/24',
46
- 'network.secure.subnet=10.99.30.0/24',
47
- 'network.internal.subnet=192.168.99.0/24',
48
58
  'dns.primary=9.9.9.9',
49
59
  'dns.fallback=1.1.1.1 8.8.8.8',
50
60
  'ssh.public_key=ssh-ed25519 AAAA== test@equivalence',
@@ -71,6 +81,37 @@ describe('system apply-config equivalence with system init --accept-defaults', (
71
81
  }
72
82
  });
73
83
 
84
+ test('the operator surface may still set addressing; the automation surface may not', async () => {
85
+ // Same key, same value, two surfaces, two answers. `system init` is the
86
+ // operator saying what the topology is; `apply-config` is a module asking to
87
+ // decide it. Only the first is an authority over the network namespace.
88
+ process.env.CELILO_DB_PATH = join(tmpDir, 'operator.db');
89
+ const viaInit = await handleSystemInit(['network.dmz.subnet=10.99.10.0/24'], {
90
+ 'accept-defaults': true,
91
+ });
92
+ expect(viaInit.success).toBe(true);
93
+ expect(loadExistingConfiguration(getDb())['network.dmz.subnet']).toBe('10.99.10.0/24');
94
+
95
+ process.env.CELILO_DB_PATH = join(tmpDir, 'automation.db');
96
+ const viaApply = await handleSystemApplyConfig(['network.dmz.subnet=10.99.10.0/24']);
97
+ expect(viaApply.success).toBe(false);
98
+ if (!viaApply.success) {
99
+ expect(viaApply.error).toContain('requires.networks');
100
+ }
101
+ });
102
+
103
+ test('a refused network key takes the whole write with it — no partial application', async () => {
104
+ // Half-applying would be worse than refusing: the caller sees an error and
105
+ // the box is left in a state neither surface intended.
106
+ process.env.CELILO_DB_PATH = join(tmpDir, 'partial.db');
107
+ const result = await handleSystemApplyConfig([
108
+ 'dns.primary=9.9.9.9',
109
+ 'network.dmz.subnet=10.99.10.0/24',
110
+ ]);
111
+ expect(result.success).toBe(false);
112
+ expect(loadExistingConfiguration(getDb())['dns.primary']).toBeUndefined();
113
+ });
114
+
74
115
  test('apply-config refuses to run with no overrides (unlike init which has defaults)', async () => {
75
116
  process.env.CELILO_DB_PATH = join(tmpDir, 'empty.db');
76
117
  const result = await handleSystemApplyConfig([]);
@@ -92,10 +133,7 @@ describe('system apply-config equivalence with system init --accept-defaults', (
92
133
 
93
134
  test('apply-config writes report the number of values applied', async () => {
94
135
  process.env.CELILO_DB_PATH = join(tmpDir, 'count.db');
95
- const result = await handleSystemApplyConfig([
96
- 'dns.primary=1.1.1.1',
97
- 'network.dmz.subnet=10.0.10.0/24',
98
- ]);
136
+ const result = await handleSystemApplyConfig(['dns.primary=1.1.1.1', 'network.bridge=vmbr9']);
99
137
  expect(result.success).toBe(true);
100
138
  if (result.success) {
101
139
  // initializeSystem() merges with defaults + computed gateways,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
- import { parseKeyValueArgs } from './system-apply-config';
11
+ import { networkWriteRefusal, parseKeyValueArgs, rejectedNetworkKeys } from './system-apply-config';
12
12
 
13
13
  describe('parseKeyValueArgs', () => {
14
14
  test('empty args → empty overrides + no errors', () => {
@@ -68,3 +68,59 @@ describe('parseKeyValueArgs', () => {
68
68
  expect(result.overrides['dns.primary']).toBe('8.8.8.8');
69
69
  });
70
70
  });
71
+
72
+ /**
73
+ * §2.2 — the write path is CLOSED, not merely unused.
74
+ *
75
+ * `apply-config` is the automation surface a module hook shells out to, and it
76
+ * is how `wireguard` and `iptables` used to write network definitions. Deleting
77
+ * those two calls stops those two modules; refusing the key stops the next one.
78
+ * The distinction matters because the authority is the point: a rule every
79
+ * caller must remember is not a rule.
80
+ */
81
+ describe('network keys are refused (celilo owns the network namespace)', () => {
82
+ test('the subnet a module used to write is rejected', () => {
83
+ expect(rejectedNetworkKeys(['network.control-plane-vpn.subnet'])).toEqual([
84
+ 'network.control-plane-vpn.subnet',
85
+ ]);
86
+ });
87
+
88
+ test('the whole network namespace is refused, not just .subnet', () => {
89
+ // A module that could set a gateway or a VLAN tag for a network it does not
90
+ // own would be redefining that network by increments, and a subnet-only rule
91
+ // would read as an invitation to do exactly that.
92
+ expect(
93
+ rejectedNetworkKeys([
94
+ 'network.dmz.subnet',
95
+ 'network.dmz.gateway',
96
+ 'network.dmz.vlan',
97
+ 'network.secure-mgmt.subnet',
98
+ ]),
99
+ ).toHaveLength(4);
100
+ });
101
+
102
+ test('network.bridge is exempt — a container-service concept, not addressing', () => {
103
+ // It is also the one network.* key with a schema default, so refusing it
104
+ // would break `system init`.
105
+ expect(rejectedNetworkKeys(['network.bridge'])).toEqual([]);
106
+ });
107
+
108
+ test('everything else still writes', () => {
109
+ expect(rejectedNetworkKeys(['dns.primary', 'ssh.public_key', 'public_dns.resolver'])).toEqual(
110
+ [],
111
+ );
112
+ });
113
+
114
+ test('the refusal says whose the namespace is, and what to do instead', () => {
115
+ // A bare "rejected" would send a module author looking for a typo. The
116
+ // message has to name the alternative, because there IS one: declare the
117
+ // network and read it.
118
+ const message = networkWriteRefusal(['network.dmz.subnet']);
119
+ expect(message).toContain('network.dmz.subnet');
120
+ expect(message).toContain('requires.networks');
121
+ expect(message).toContain('$system:network.<name>.subnet');
122
+ // And the operator's own escape hatches, which are not closed.
123
+ expect(message).toContain('celilo system config set');
124
+ expect(message).toContain('celilo system discover-network');
125
+ });
126
+ });
@@ -29,6 +29,44 @@ interface ParsedInput {
29
29
  errors: string[];
30
30
  }
31
31
 
32
+ /**
33
+ * Keys this command will not write, however they arrive.
34
+ *
35
+ * `apply-config` is the automation surface — the one a module hook can shell out
36
+ * to — and networks are celilo's to define, never a module's
37
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
38
+ * Closing it here rather than trusting each module is the point: an authority
39
+ * that depends on every caller behaving is not an authority.
40
+ *
41
+ * This is deliberately the WHOLE `network.` namespace and not just `.subnet`.
42
+ * A module that could set a gateway or a VLAN tag for a network it does not own
43
+ * would be redefining that network by increments, and the subnet-only rule would
44
+ * read as an invitation to do exactly that.
45
+ *
46
+ * `network.bridge` is exempt: it is a container-service concept (a Proxmox bridge
47
+ * name like `vmbr0`), not addressing, and it is the one `network.` key that
48
+ * carries a schema default.
49
+ */
50
+ const NETWORK_KEY = /^network\./;
51
+ const NETWORK_KEY_EXEMPT = new Set(['network.bridge']);
52
+
53
+ export function rejectedNetworkKeys(keys: string[]): string[] {
54
+ return keys.filter((key) => NETWORK_KEY.test(key) && !NETWORK_KEY_EXEMPT.has(key));
55
+ }
56
+
57
+ export function networkWriteRefusal(keys: string[]): string {
58
+ return [
59
+ `Refusing to write ${keys.length === 1 ? 'a network key' : 'network keys'}: ${keys.join(', ')}.`,
60
+ '',
61
+ "Networks are celilo's to define, not a module's. A module declares the networks it",
62
+ 'needs under `requires.networks` and reads them with `$system:network.<name>.subnet`;',
63
+ 'celilo supplies the value, asking the operator when it does not already hold one.',
64
+ '',
65
+ 'If you are the operator: `celilo system config set <key> <value>`.',
66
+ 'If this is the management box recording its own network: `celilo system discover-network`.',
67
+ ].join('\n');
68
+ }
69
+
32
70
  /**
33
71
  * Pure parser for the positional key=value arguments. Exported so the
34
72
  * test suite can exercise it without spinning up the CLI.
@@ -113,6 +151,11 @@ export async function handleSystemApplyConfig(
113
151
  };
114
152
  }
115
153
 
154
+ const refused = rejectedNetworkKeys(Object.keys(overrides));
155
+ if (refused.length > 0) {
156
+ return { success: false, error: networkWriteRefusal(refused) };
157
+ }
158
+
116
159
  const db = getDb();
117
160
  try {
118
161
  const applied = initializeSystem(db, overrides);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `celilo system discover-network` — record the network this box sits on.
3
+ *
4
+ * The celilo-side half of "celilo owns the network namespace"
5
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
6
+ * `system apply-config` refuses `network.*` keys outright, so this is the surface
7
+ * that writes the one network nobody can be asked about: the management box's
8
+ * own, which celilo reads off the kernel rather than out of anyone's config.
9
+ *
10
+ * Idempotent and non-destructive — a value already set is left alone, so a
11
+ * redeploy cannot clobber an operator's addressing.
12
+ */
13
+
14
+ import { getDb } from '../../db/client';
15
+ import { discoverAndRecordNetwork } from '../../services/network-discovery';
16
+ import type { CommandResult } from '../types';
17
+
18
+ export async function handleSystemDiscoverNetwork(): Promise<CommandResult> {
19
+ const result = discoverAndRecordNetwork(getDb());
20
+
21
+ if (result.applied.length === 0) {
22
+ // Not an error. "Nothing to record" is the ordinary outcome of a redeploy,
23
+ // and an undiscoverable route is a fact to report rather than a failure to
24
+ // abort a deploy on — the operator can set the value by hand.
25
+ return {
26
+ success: true,
27
+ message: `No network recorded: ${result.skipped ?? 'nothing to do'}.`,
28
+ };
29
+ }
30
+
31
+ return {
32
+ success: true,
33
+ message: `Recorded the network this box sits on:\n ${result.applied.join('\n ')}`,
34
+ };
35
+ }
@@ -620,6 +620,7 @@ export async function getCompletions(words: string[], current: number): Promise<
620
620
  const subcommands = [
621
621
  'init',
622
622
  'apply-config',
623
+ 'discover-network',
623
624
  'config',
624
625
  'secret',
625
626
  'vault-password',