@celilo/cli 2.0.0 → 2.2.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 +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -54,7 +54,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
54
54
|
|
|
55
55
|
## Celilo's own infrastructure (self-hosted)
|
|
56
56
|
|
|
57
|
-
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations,
|
|
57
|
+
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations). Has **no `on_install`** — celilo initialises the box itself during the deploy (`bootstrapControlPlane`), because a jailed hook cannot spawn the CLI it used to reach (celilo#1225). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
|
|
58
58
|
- **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. Its `sweep_revisions` hook is the store's ONLY delete path — `yank` flips a boolean in the index entry and frees nothing, so before it existed every build revision ever published was retained forever and a `+N` revision is auto-assigned on every publish. The production store reached 18 GB of which ~93% was superseded history, filled a 20 GB disk and took the release pipeline down. The sweep keeps the newest `sweep_keep_build_revisions` (default 1) of EVERY release, so no release can disappear and a manual rollback still works — that is what makes the hourly `timer.tick.1h` subscription defensible. It removes the index line BEFORE the payload, because the download route reads the payload file directly and never consults the index: that order leaves an interrupted sweep with an unlisted-but-served version rather than a listed one that 404s, and the orphan it leaves is reclaimed by the next run. On demand with `celilo module run-hook celilo-registry sweep_revisions` (add `dry_run=true` to see the plan). **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
|
|
59
59
|
- **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
|
|
60
60
|
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -205,6 +205,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
205
205
|
- **Remote-ops broker (reachability scoped by the credential, stage 3 / D12)** — `apps/celilo/src/hooks/remote-broker.ts` (`startRemoteBroker`, `RemoteAccessPolicy`) answering a SECOND socket beside the capability one, with the policy in `apps/celilo/src/services/remote-access.ts` (`remoteAccessPolicy`) and the asking half inside `@celilo/capabilities`' own remote primitives (`packages/capabilities/src/remote.ts`, "The hook remote-ops bridge"). The jail binds no `~/.ssh`, so a hand-built `ssh` cannot authenticate; the primitives detect `CELILO_HOOK_REMOTE_SOCKET`, send each operation as a STRUCTURED request (never a shell string — the broker rebuilds the ssh line itself), and the broker checks the target against `ownedSystemModuleIds` + `getModuleSystems` before running anything, refusing with the module, the target, and the capability route named. Requests carrying the module's OWN credential (an `identityFile` crossing as content and materialised per call, or `installAuthorizedKey`'s password — the cPanel case) are scoped by that credential instead; an explicit non-root user likewise, because the fleet key's authority is root on fleet systems. Stream primitives' LOCAL paths are confined to the run's granted roots (stateDir, screenshots, generated/, declared path inputs), or `streamBackup` would be a write-as-celilo oracle. Attribution is by the module that PERFORMS the operation: a hook's request to the hook's module (the nine `invokeHook` sites build the policy), a provider's transport to the provider's module — providers run in-process and do not cross this socket, and `public_web`'s hand-built upload was replaced by the Ansible static-content converge (capability-owned-tables stage 4, celilo#1014). Residual, recorded rather than papered over: a hook can still `fetch()` any HTTP endpoint directly; only `probeHttp` consults the target check.
|
|
206
206
|
- **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. `~/.ssh` is deliberately NOT bound (stage 3, D12): withholding the credential is what makes the remote-ops broker's target check a boundary. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. `CELILO_HOOK_JAIL` = `auto` (default) / `required` / `off`. macOS has a `sandbox-exec` backend in the union, but `auto` defers on it (ce-29z: sandbox-exec DENIES undeclared writes where bubblewrap masks them, and D14's declared-path mechanism is not built), so the hook runs unjailed with the mode recorded; `required` bypasses the deferral for an operator who opts in. An `unjailed` record carries `lastJailed` (the jailed record it replaced on the same host), which is what the `hook_jail` self-monitor reads (`services/alerting/hook-jail.ts`, created unsuppressible by `celilo monitor add hook_jail`) to alert on a host that used to jail and has stopped. `celilo system doctor`'s "Hook execution" section (`renderHookExecutionSection`) reports the live mode and, when unjailed, why.
|
|
207
207
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
208
|
+
- **Control-plane bootstrap (celilo initialises its own box)** — `apps/celilo/src/services/control-plane-bootstrap.ts` (`bootstrapControlPlane`) with `apps/celilo/src/services/dns-discovery.ts` (`discoverDns`) beside `network-discovery.ts`. Called from `module-deploy.ts` at the point `on_install` runs, for `CONTROL_PLANE_MODULE_ID` (exported from `services/deployed-systems.ts`, replacing three private copies of the string). Reads the box's upstream resolvers, mints the fleet key via `ensureFleetKey`, writes `dns.*` + `ssh.public_key` in ONE `initializeSystem` call, records the network via `discoverAndRecordNetwork`, then polls `checkDispatcher` and FAILS the deploy if no dispatcher answers. **It is not a hook, and that is the point** (celilo#1225): it was `modules/celilo-mgmt/scripts/on_install.ts`, which reached all of this by spawning the `celilo` CLI — impossible inside the jail, whose mount set binds no `/usr/bin`, no `/usr/local/bin` and no shell, so the deploy ran Ansible clean and then died in its own install hook on every host with a jail backend. celilo-mgmt is never deployed to a remote box, so the host being configured is always the host celilo runs on and the hook was a process boundary with celilo on both sides. Self-registering the management host into its own pool did NOT move: celilo places a module only on a machine already in the pool. The dispatcher read is celilo's four-part `checkDispatcher`, not the "is a process up?" probe the hook used.
|
|
208
209
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
209
210
|
- **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium). Two directions, with DELIBERATELY OPPOSITE failure semantics:
|
|
210
211
|
- **Outbound** (`maybeRunAspectForTrigger`, `on_install`): one provider's aspect across the whole fleet, enumerated at that instant. A failure never fails the provider's own deploy — aspects are forward-progress and a partial fleet converges on the next fan-out.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Who owns a `module_configs` row (hook-owned-state design D7).
|
|
2
|
+
--
|
|
3
|
+
-- One column, two changes: hook-owned-state needs it to enforce "only a hook
|
|
4
|
+
-- writes source: hook", and `derived-value-recomputation` needs it to tell an
|
|
5
|
+
-- operator row from a derived-seeded one. Whichever change landed first owns
|
|
6
|
+
-- the migration; this is it.
|
|
7
|
+
--
|
|
8
|
+
-- Purely additive and NULLABLE on purpose. NULL means "written before the
|
|
9
|
+
-- column existed" — operator intent, interview writes, legacy derived seeds,
|
|
10
|
+
-- framework keys alike — and stays the value for operator writes until
|
|
11
|
+
-- derived-value-recomputation lands its own classification. There is no
|
|
12
|
+
-- backfill: nothing in an existing row can prove who wrote it, and a guess
|
|
13
|
+
-- backfilled as fact is worse than an honest NULL.
|
|
14
|
+
--
|
|
15
|
+
-- The first writer of a non-NULL value is the hook config store
|
|
16
|
+
-- (`hooks/hook-store.ts`), whose `set` writes 'hook' through
|
|
17
|
+
-- `upsertModuleConfig`. The manifest refuses `module config set` on any
|
|
18
|
+
-- `source: hook` variable, so only a hook can produce the value.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE `module_configs` ADD `source` text;
|
|
@@ -218,6 +218,13 @@
|
|
|
218
218
|
"when": 1784400000000,
|
|
219
219
|
"tag": "0030_drop_module_builds_environment",
|
|
220
220
|
"breakpoints": true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"idx": 31,
|
|
224
|
+
"version": "6",
|
|
225
|
+
"when": 1788768000000,
|
|
226
|
+
"tag": "0031_module_config_source",
|
|
227
|
+
"breakpoints": true
|
|
221
228
|
}
|
|
222
229
|
]
|
|
223
|
-
}
|
|
230
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^4.
|
|
61
|
+
"@celilo/capabilities": "^4.2.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.11.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
@@ -102,7 +102,8 @@
|
|
|
102
102
|
},
|
|
103
103
|
"firewall.trusted_subnets": {
|
|
104
104
|
"type": "string",
|
|
105
|
-
"
|
|
105
|
+
"format": "cidr-list",
|
|
106
|
+
"description": "Operator-declared trusted source subnets permitted to reach every managed zone, as a comma-separated CIDR list (10.0.0.0/24,10.1.0.0/24) or a JSON array ([\"10.0.0.0/24\"]). ADDITIVE to the control-plane subnet celilo derives and to the sources modules register \u2014 it cannot drop control-plane trust, which would block the SSH every hook and converge runs over."
|
|
106
107
|
},
|
|
107
108
|
"dns.primary": {
|
|
108
109
|
"type": "string",
|
|
@@ -273,6 +273,67 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
273
273
|
});
|
|
274
274
|
});
|
|
275
275
|
|
|
276
|
+
describe('the state web overlay (celilo#1265)', () => {
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
afterEach(() => {
|
|
282
|
+
execSyncSpy.mockRestore();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// A jailed hook cannot write into its own module tree, so generated site
|
|
286
|
+
// files land in `<module>/state/site`. The upload's content hash must see
|
|
287
|
+
// them, or the release directory never refreshes when the overlay changes.
|
|
288
|
+
test('the upload hash and file count include the state overlay', async () => {
|
|
289
|
+
const sourceDir = mkdtempSync(join(tmpdir(), 'publish-overlay-'));
|
|
290
|
+
const overlayDir = join(mkdtempSync(join(tmpdir(), 'publish-overlay-state-')));
|
|
291
|
+
writeFileSync(join(sourceDir, 'index.html'), '<html></html>', 'utf-8');
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const { ops, routes } = makeRouteOps();
|
|
295
|
+
const cap = createPublicWeb({
|
|
296
|
+
webRoot: sourceDir,
|
|
297
|
+
stateWebRoot: overlayDir,
|
|
298
|
+
moduleId: 'lunacycle',
|
|
299
|
+
logger: noopLogger,
|
|
300
|
+
config: {
|
|
301
|
+
target_ip: '10.0.10.20/24',
|
|
302
|
+
hostname: 'www',
|
|
303
|
+
primary_domain: 'example.com',
|
|
304
|
+
email: 'admin@example.com',
|
|
305
|
+
},
|
|
306
|
+
secrets: {},
|
|
307
|
+
convergeStaticContent: async () => {},
|
|
308
|
+
routeOps: ops,
|
|
309
|
+
hostnames: ['www.example.com'],
|
|
310
|
+
caddyModuleId: 'caddy',
|
|
311
|
+
dnsManagedDomains: ['www.example.com'],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const hashOf = () =>
|
|
315
|
+
routes.find((r) => r.moduleId === 'lunacycle' && r.path === '/lunacycle')?.contentHash;
|
|
316
|
+
|
|
317
|
+
// No overlay yet: one file, and the hash covers only the web root.
|
|
318
|
+
const before = await cap.publishStaticSite({ path: '/lunacycle' });
|
|
319
|
+
const hashBefore = hashOf();
|
|
320
|
+
expect(before.filesUploaded).toBe(1);
|
|
321
|
+
expect(hashBefore).toBeTruthy();
|
|
322
|
+
|
|
323
|
+
// A generated file in state must move the hash, or a rebuilt host's
|
|
324
|
+
// converge would re-serve the old release and the file would never land.
|
|
325
|
+
writeFileSync(join(overlayDir, 'ca.crt'), '---CERT---', 'utf-8');
|
|
326
|
+
const after = await cap.publishStaticSite({ path: '/lunacycle' });
|
|
327
|
+
expect(after.filesUploaded).toBe(2);
|
|
328
|
+
expect(hashOf()).toBeTruthy();
|
|
329
|
+
expect(hashOf()).not.toBe(hashBefore);
|
|
330
|
+
} finally {
|
|
331
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
332
|
+
rmSync(overlayDir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
276
337
|
describe('registerReverseProxy', () => {
|
|
277
338
|
beforeEach(() => {
|
|
278
339
|
execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
|
|
@@ -8,11 +8,39 @@
|
|
|
8
8
|
* was never a lack of information but a lack of celilo saying it.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
|
+
// The command reaches boxes ONLY through these two modules, and both are
|
|
17
|
+
// mocked below so the tests can drive the live-read and fallback paths without
|
|
18
|
+
// a real machine. Spreading the actual module keeps every other export intact
|
|
19
|
+
// for anything else that loads them.
|
|
20
|
+
const actualDetector = await import('../../services/machine-detector');
|
|
21
|
+
const actualKeyManager = await import('../../services/ssh-key-manager');
|
|
22
|
+
|
|
23
|
+
type Detected = {
|
|
24
|
+
interfaces: Array<{ name: string; ipAddress: string; zone?: string }>;
|
|
25
|
+
role: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let liveRead: (ip: string, user: string, keyPath: string) => Promise<Detected>;
|
|
29
|
+
let localRead: () => Promise<Detected>;
|
|
30
|
+
let tempKeyWriter: (machineId: string) => Promise<string>;
|
|
31
|
+
|
|
32
|
+
mock.module('../../services/machine-detector', () => ({
|
|
33
|
+
...actualDetector,
|
|
34
|
+
detectNetworkInterfaces: (ip: string, user: string, keyPath: string) =>
|
|
35
|
+
liveRead(ip, user, keyPath),
|
|
36
|
+
detectNetworkInterfacesLocal: () => localRead(),
|
|
37
|
+
}));
|
|
38
|
+
mock.module('../../services/ssh-key-manager', () => ({
|
|
39
|
+
...actualKeyManager,
|
|
40
|
+
writeTemporarySshKey: (machineId: string) => tempKeyWriter(machineId),
|
|
41
|
+
deleteTemporarySshKey: () => {},
|
|
42
|
+
}));
|
|
43
|
+
|
|
16
44
|
let testDir: string;
|
|
17
45
|
let logs: string[];
|
|
18
46
|
const originalLog = console.log;
|
|
@@ -29,6 +57,10 @@ beforeEach(() => {
|
|
|
29
57
|
testDir = mkdtempSync(join(tmpdir(), 'celilo-fw-iface-'));
|
|
30
58
|
process.env.CELILO_DB_PATH = join(testDir, 'test.db');
|
|
31
59
|
process.env.CELILO_DATA_DIR = testDir;
|
|
60
|
+
// Key decryption (getMachineSshKey) needs a master key.
|
|
61
|
+
const masterKeyPath = join(testDir, 'master.key');
|
|
62
|
+
writeFileSync(masterKeyPath, 'a'.repeat(64));
|
|
63
|
+
process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
|
|
32
64
|
captureLogs();
|
|
33
65
|
});
|
|
34
66
|
|
|
@@ -36,6 +68,8 @@ afterEach(() => {
|
|
|
36
68
|
console.log = originalLog;
|
|
37
69
|
rmSync(testDir, { recursive: true, force: true });
|
|
38
70
|
process.env.CELILO_DB_PATH = undefined;
|
|
71
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
72
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
39
73
|
});
|
|
40
74
|
|
|
41
75
|
describe('celilo firewall interface list', () => {
|
|
@@ -71,15 +105,130 @@ describe('celilo firewall interface list', () => {
|
|
|
71
105
|
* me — it would pass no matter what they said. The property below is different:
|
|
72
106
|
* it is a claim about what the command may DO, and it can genuinely fail.
|
|
73
107
|
*/
|
|
74
|
-
describe('
|
|
75
|
-
test('
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
108
|
+
describe('no side effects on the box', () => {
|
|
109
|
+
test('reaches a box only through the read-only detectors', async () => {
|
|
110
|
+
// #1287's CLI half came from the stored-read-only version of this command:
|
|
111
|
+
// a snapshot taken at machine add kept reading ALIEN after the interface
|
|
112
|
+
// was gone, so the command contradicted the converge it claims to preview.
|
|
113
|
+
// The fix reads the box live through the same detectors machine add uses —
|
|
114
|
+
// read-only by construction. What is still forbidden is any MUTATING
|
|
115
|
+
// remote primitive, and any hand-built ssh that could grow one silently.
|
|
79
116
|
const src = await Bun.file(join(import.meta.dir, 'firewall-interface-list.ts')).text();
|
|
80
117
|
|
|
81
118
|
expect(src).not.toContain('runAppCommand');
|
|
82
119
|
expect(src).not.toContain('execFileSync');
|
|
83
120
|
expect(src).not.toContain('ssh ');
|
|
121
|
+
// The live read must come from the shared detector, not a private path.
|
|
122
|
+
expect(src).toContain('detectNetworkInterfaces');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The live-read contract, against a machine whose STORED snapshot is stale —
|
|
128
|
+
* the #1287 shape: the alien cable was attached when `machine add` recorded
|
|
129
|
+
* the interfaces, and nothing refreshes that snapshot afterwards.
|
|
130
|
+
*/
|
|
131
|
+
describe('reads the box live', () => {
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
liveRead = async () => {
|
|
134
|
+
throw new Error('liveRead not configured for this test');
|
|
135
|
+
};
|
|
136
|
+
localRead = async () => {
|
|
137
|
+
throw new Error('localRead not configured for this test');
|
|
138
|
+
};
|
|
139
|
+
tempKeyWriter = async () => {
|
|
140
|
+
throw new Error('tempKeyWriter not configured for this test');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const { getDb } = await import('../../db/client');
|
|
144
|
+
const { systemConfig } = await import('../../db/schema');
|
|
145
|
+
const { addMachine } = await import('../../services/machine-pool');
|
|
146
|
+
|
|
147
|
+
await getDb()
|
|
148
|
+
.insert(systemConfig)
|
|
149
|
+
.values([
|
|
150
|
+
{ key: 'network.internal.subnet', value: '10.226.1.0/24' },
|
|
151
|
+
{ key: 'network.app.subnet', value: '10.226.20.0/24' },
|
|
152
|
+
{ key: 'network.dmz.subnet', value: '10.226.10.0/24' },
|
|
153
|
+
{ key: 'network.secure.subnet', value: '10.226.30.0/24' },
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
await addMachine({
|
|
157
|
+
hostname: 'fw-main',
|
|
158
|
+
zone: 'internal',
|
|
159
|
+
ipAddress: '10.226.1.254',
|
|
160
|
+
sshUser: 'root',
|
|
161
|
+
sshKey: 'test-key-material',
|
|
162
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
|
|
163
|
+
role: 'host',
|
|
164
|
+
interfaces: [
|
|
165
|
+
{ name: 'eth0', ipAddress: '10.226.20.1', zone: 'app' },
|
|
166
|
+
{ name: 'eth1', ipAddress: '10.226.10.1', zone: 'dmz' },
|
|
167
|
+
{ name: 'eth2', ipAddress: '10.226.1.254', zone: 'internal' },
|
|
168
|
+
{ name: 'eth3', ipAddress: '10.226.30.1', zone: 'secure' },
|
|
169
|
+
// The cable nobody documented, recorded because it was attached when
|
|
170
|
+
// the machine was added. It left the box afterwards; the snapshot
|
|
171
|
+
// never heard about it.
|
|
172
|
+
{ name: 'eth4', ipAddress: '172.31.99.2', zone: 'unknown' },
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('an interface removed after machine add no longer reads ALIEN', async () => {
|
|
178
|
+
let keyWritten = false;
|
|
179
|
+
tempKeyWriter = async () => {
|
|
180
|
+
keyWritten = true;
|
|
181
|
+
return '/tmp/celilo-fw-iface-test.key';
|
|
182
|
+
};
|
|
183
|
+
liveRead = async (ip, user) => {
|
|
184
|
+
// The read went to the box named in the machine record, with its stored
|
|
185
|
+
// credentials — the same targeting the converge uses.
|
|
186
|
+
expect(ip).toBe('10.226.1.254');
|
|
187
|
+
expect(user).toBe('root');
|
|
188
|
+
return {
|
|
189
|
+
interfaces: [
|
|
190
|
+
{ name: 'eth0', ipAddress: '10.226.20.1' },
|
|
191
|
+
{ name: 'eth1', ipAddress: '10.226.10.1' },
|
|
192
|
+
{ name: 'eth2', ipAddress: '10.226.1.254' },
|
|
193
|
+
{ name: 'eth3', ipAddress: '10.226.30.1' },
|
|
194
|
+
],
|
|
195
|
+
role: 'router',
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const { handleFirewallInterfaceList } = await import('./firewall-interface-list');
|
|
200
|
+
const result = await handleFirewallInterfaceList(['fw-main'], {});
|
|
201
|
+
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
expect(keyWritten).toBe(true);
|
|
204
|
+
const out = logs.join('\n');
|
|
205
|
+
// eth4 is gone from the box, so the answer says so — even though the
|
|
206
|
+
// stored snapshot still carries it.
|
|
207
|
+
expect(out).not.toContain('172.31.99.2');
|
|
208
|
+
expect(out).not.toContain('ALIEN');
|
|
209
|
+
for (const zone of ['app', 'dmz', 'internal', 'secure']) {
|
|
210
|
+
expect(out).toContain(`zone:${zone}`);
|
|
211
|
+
}
|
|
212
|
+
expect(out).not.toContain('could not read the box live');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a box that cannot be reached says so, and labels the snapshot as stale', async () => {
|
|
216
|
+
tempKeyWriter = async () => '/tmp/celilo-fw-iface-test.key';
|
|
217
|
+
liveRead = async () => {
|
|
218
|
+
throw new Error('SSH command failed after 3 attempts: connection refused');
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const { handleFirewallInterfaceList } = await import('./firewall-interface-list');
|
|
222
|
+
const result = await handleFirewallInterfaceList(['fw-main'], {});
|
|
223
|
+
|
|
224
|
+
// Unreachable is not a classification result, but the stored snapshot is
|
|
225
|
+
// still the best available answer — as long as it says what it is.
|
|
226
|
+
expect(result.success).toBe(true);
|
|
227
|
+
const out = logs.join('\n');
|
|
228
|
+
expect(out).toContain('could not read the box live');
|
|
229
|
+
expect(out).toContain('may be stale');
|
|
230
|
+
// The stale eth4 is reported, under the label that keeps it from being
|
|
231
|
+
// mistaken for a current finding.
|
|
232
|
+
expect(out).toContain('ALIEN');
|
|
84
233
|
});
|
|
85
234
|
});
|
|
@@ -9,15 +9,34 @@
|
|
|
9
9
|
* The condition that produced `fw-keeper.sh` was not that celilo lacked the
|
|
10
10
|
* information; it was that celilo never said it.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* No side effects: it reads the LIVE interface table the same way the next
|
|
13
|
+
* converge will — classification is recomputed, never read from a stored copy
|
|
14
|
+
* (design D4: a stored classification goes stale) — plus the declarations, and
|
|
15
|
+
* classifies in memory. The only remote operations are the read-only interface
|
|
16
|
+
* detectors `machine add` uses. When the box cannot be reached it says so, and
|
|
17
|
+
* falls back to the interface snapshot recorded at machine add, labelled as
|
|
18
|
+
* what it is.
|
|
19
|
+
*
|
|
20
|
+
* The stored-read-only version of this command was the whole of the CLI half
|
|
21
|
+
* of #1287: the snapshot is taken at machine add, so an interface removed after
|
|
22
|
+
* it was recorded kept reading ALIEN forever, and an operator who did exactly
|
|
23
|
+
* what the converge refusal told them to do was told their fix had not worked.
|
|
15
24
|
*/
|
|
16
25
|
|
|
17
26
|
import { classifyInterfaces, isPubliclyRoutable } from '@celilo/capabilities';
|
|
18
27
|
import { getDb } from '../../db/client';
|
|
19
28
|
import { listFirewallIps, readDeclaredNetworks } from '../../hooks/capability-loader';
|
|
29
|
+
import {
|
|
30
|
+
detectNetworkInterfaces,
|
|
31
|
+
detectNetworkInterfacesLocal,
|
|
32
|
+
} from '../../services/machine-detector';
|
|
20
33
|
import { listMachines } from '../../services/machine-pool';
|
|
34
|
+
import {
|
|
35
|
+
LOCAL_MACHINE_IP,
|
|
36
|
+
deleteTemporarySshKey,
|
|
37
|
+
writeTemporarySshKey,
|
|
38
|
+
} from '../../services/ssh-key-manager';
|
|
39
|
+
import type { Machine } from '../../types/infrastructure';
|
|
21
40
|
import { celiloIntro } from '../prompts';
|
|
22
41
|
import type { CommandResult } from '../types';
|
|
23
42
|
|
|
@@ -36,6 +55,41 @@ async function declaredZones(): Promise<Array<{ zone: string; subnet: string }>>
|
|
|
36
55
|
return readDeclaredNetworks(getDb());
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/** What the classifier consumes, whatever the source. */
|
|
59
|
+
interface NamedInterface {
|
|
60
|
+
name: string;
|
|
61
|
+
ip: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The interface table as the box holds it RIGHT NOW, via the same read-only
|
|
66
|
+
* detectors `machine add` runs. The converge reads the box live too, so this
|
|
67
|
+
* is the input its classification will see.
|
|
68
|
+
*/
|
|
69
|
+
async function readLiveInterfaces(machine: Machine): Promise<NamedInterface[]> {
|
|
70
|
+
const toNamed = (list: Array<{ name: string; ipAddress: string }>): NamedInterface[] =>
|
|
71
|
+
list.map(({ name, ipAddress }) => ({ name, ip: ipAddress }));
|
|
72
|
+
|
|
73
|
+
// The management box registers itself as 127.0.0.1 with no stored SSH key
|
|
74
|
+
// and is reached locally, the same way machine add reaches it.
|
|
75
|
+
if (machine.ipAddress === LOCAL_MACHINE_IP) {
|
|
76
|
+
const { interfaces } = await detectNetworkInterfacesLocal();
|
|
77
|
+
return toNamed(interfaces);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const keyPath = await writeTemporarySshKey(machine.id);
|
|
81
|
+
try {
|
|
82
|
+
const { interfaces } = await detectNetworkInterfaces(
|
|
83
|
+
machine.ipAddress,
|
|
84
|
+
machine.sshUser,
|
|
85
|
+
keyPath,
|
|
86
|
+
);
|
|
87
|
+
return toNamed(interfaces);
|
|
88
|
+
} finally {
|
|
89
|
+
deleteTemporarySshKey(machine.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
39
93
|
/** One line per interface, explaining the role rather than just naming it. */
|
|
40
94
|
function describe(role: string, zone: string | undefined, ip: string): string {
|
|
41
95
|
if (role === 'zone') return `zone:${zone}`;
|
|
@@ -88,10 +142,22 @@ export async function handleFirewallInterfaceList(
|
|
|
88
142
|
for (const machine of targets) {
|
|
89
143
|
console.log(`\n${machine.hostname} (${machine.ipAddress})`);
|
|
90
144
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
})
|
|
145
|
+
let interfaces: NamedInterface[];
|
|
146
|
+
try {
|
|
147
|
+
interfaces = await readLiveInterfaces(machine);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// An unreachable box is not a classification result. Name the fallback
|
|
150
|
+
// for what it is, so a stale ALIEN is never mistaken for a current one.
|
|
151
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
152
|
+
console.log(
|
|
153
|
+
' could not read the box live — classifying the interface snapshot recorded at machine add, which may be stale:',
|
|
154
|
+
);
|
|
155
|
+
console.log(` ${reason}`);
|
|
156
|
+
interfaces = (machine.interfaces ?? []).map((i) => ({
|
|
157
|
+
name: i.name,
|
|
158
|
+
ip: i.ipAddress,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
95
161
|
if (interfaces.length === 0) {
|
|
96
162
|
console.log(' no interfaces recorded — re-run `celilo machine add` to detect them');
|
|
97
163
|
continue;
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
8
|
import { isPubliclyRoutable } from '@celilo/capabilities';
|
|
10
9
|
import { getDb } from '../../db/client';
|
|
11
10
|
import type { NetworkZone } from '../../db/schema';
|
|
12
11
|
import { askText, withInterviewSession } from '../../services/bus-interview';
|
|
12
|
+
import { findFleetPrivateKey, fleetKeySearchDirs } from '../../services/fleet-key';
|
|
13
13
|
import {
|
|
14
14
|
detectMachineInfo,
|
|
15
15
|
detectMachineInfoLocal,
|
|
@@ -30,60 +30,18 @@ import { celiloIntro, celiloOutro } from '../prompts';
|
|
|
30
30
|
import type { CommandResult } from '../types';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* The private key matching `ssh.public_key`, or null.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Delegates the search to `findFleetPrivateKey`, which lives beside the mint
|
|
36
|
+
* so the two cannot name different directories. This function used to derive
|
|
37
|
+
* its own `join(HOME, '.ssh')`, which meant it could not see the key
|
|
38
|
+
* `ensureFleetKey` writes into celilo's data directory, so a fleet whose key
|
|
39
|
+
* celilo minted refused every `machine add` (celilo#1240).
|
|
38
40
|
*/
|
|
39
41
|
function findSshPrivateKey(): string | null {
|
|
40
42
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const publicKey = config['ssh.public_key'];
|
|
44
|
-
|
|
45
|
-
if (!publicKey) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Extract key type from public key (e.g., "ssh-ed25519", "ssh-rsa")
|
|
50
|
-
const keyType = publicKey.split(' ')[0];
|
|
51
|
-
|
|
52
|
-
const sshDir = join(process.env.HOME || '~', '.ssh');
|
|
53
|
-
if (!existsSync(sshDir)) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Common private key filenames based on type
|
|
58
|
-
const keyFileMap: Record<string, string[]> = {
|
|
59
|
-
'ssh-ed25519': ['id_ed25519'],
|
|
60
|
-
'ssh-rsa': ['id_rsa'],
|
|
61
|
-
'ecdsa-sha2-nistp256': ['id_ecdsa'],
|
|
62
|
-
'ecdsa-sha2-nistp384': ['id_ecdsa'],
|
|
63
|
-
'ecdsa-sha2-nistp521': ['id_ecdsa'],
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const candidateFiles = keyFileMap[keyType] || ['id_rsa', 'id_ed25519', 'id_ecdsa'];
|
|
67
|
-
|
|
68
|
-
// Try common key files
|
|
69
|
-
for (const keyFile of candidateFiles) {
|
|
70
|
-
const keyPath = join(sshDir, keyFile);
|
|
71
|
-
if (existsSync(keyPath)) {
|
|
72
|
-
// Verify this is the matching private key by checking if public key exists
|
|
73
|
-
const pubKeyPath = `${keyPath}.pub`;
|
|
74
|
-
if (existsSync(pubKeyPath)) {
|
|
75
|
-
const pubKeyContent = readFileSync(pubKeyPath, 'utf8').trim();
|
|
76
|
-
// Check if public keys match (compare key type and key data, ignore comment)
|
|
77
|
-
const [sysType, sysKey] = publicKey.split(' ');
|
|
78
|
-
const [fileType, fileKey] = pubKeyContent.split(' ');
|
|
79
|
-
if (sysType === fileType && sysKey === fileKey) {
|
|
80
|
-
return keyPath;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return null;
|
|
43
|
+
const publicKey = loadExistingConfiguration(getDb())['ssh.public_key'];
|
|
44
|
+
return publicKey ? findFleetPrivateKey(publicKey) : null;
|
|
87
45
|
} catch {
|
|
88
46
|
return null;
|
|
89
47
|
}
|
|
@@ -200,10 +158,9 @@ export async function handleMachineAdd(
|
|
|
200
158
|
if (!detectedKeyPath) {
|
|
201
159
|
return {
|
|
202
160
|
success: false,
|
|
203
|
-
error:
|
|
204
|
-
|
|
205
|
-
'
|
|
206
|
-
'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
|
|
161
|
+
error: `Cannot find SSH private key.\n\nThe ssh.public_key system config is set, but no private key matching it was found in:\n${fleetKeySearchDirs()
|
|
162
|
+
.map((dir) => ` ${dir}`)
|
|
163
|
+
.join('\n')}\n\nSpecify manually with: --ssh-key-file <path>`,
|
|
207
164
|
};
|
|
208
165
|
}
|
|
209
166
|
sshKeyPath = detectedKeyPath;
|
|
@@ -10,7 +10,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
|
-
import { modules } from '../../db/schema';
|
|
13
|
+
import { moduleConfigs, modules } from '../../db/schema';
|
|
14
14
|
import { resolveDeployPosture } from '../../services/deploy-posture';
|
|
15
15
|
import {
|
|
16
16
|
FRAMEWORK_CONFIG_KEYS,
|
|
@@ -41,6 +41,7 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
41
41
|
{ name: 'vmid', type: 'integer', source: 'infrastructure' },
|
|
42
42
|
{ name: 'target_node', type: 'string', source: 'infrastructure' },
|
|
43
43
|
{ name: 'app_port', type: 'integer' },
|
|
44
|
+
{ name: 'public_ip', type: 'string', source: 'hook' },
|
|
44
45
|
],
|
|
45
46
|
},
|
|
46
47
|
},
|
|
@@ -79,6 +80,24 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
79
80
|
expect(result.success).toBe(true);
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
// Hook-owned-state task 2.4 (design D2/D6): a `source: hook` variable is
|
|
84
|
+
// written ONLY by the module's own hook via `context.config.set`. A hand set
|
|
85
|
+
// value would be meaningless — the next hook run overwrites it — so the
|
|
86
|
+
// command refuses rather than reporting success over nothing.
|
|
87
|
+
test('rejects a hook-owned key and points at the accessor', async () => {
|
|
88
|
+
const result = await handleModuleConfigSet(['testmod', 'public_ip', '203.0.113.7']);
|
|
89
|
+
expect(result.success).toBe(false);
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
expect(result.error).toContain('source: hook');
|
|
92
|
+
expect(result.error).toContain('not operator-settable');
|
|
93
|
+
expect(result.error).toContain('context.config.set');
|
|
94
|
+
}
|
|
95
|
+
// And no row may exist — a refusal that leaves the row behind is the
|
|
96
|
+
// accept-then-override bug in a different coat.
|
|
97
|
+
const rows = db.select().from(moduleConfigs).all();
|
|
98
|
+
expect(rows).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
82
101
|
test('still rejects an undeclared key, and omits infra keys from the valid-keys hint', async () => {
|
|
83
102
|
const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
|
|
84
103
|
expect(result.success).toBe(false);
|
|
@@ -339,7 +339,7 @@ async function handlePublicRegistryImport(
|
|
|
339
339
|
// Download the .netapp
|
|
340
340
|
const tmpPath = join(tmpdir(), `${name}-${latest.vers}-${Date.now()}.netapp`);
|
|
341
341
|
try {
|
|
342
|
-
const pkgData = await client.download(name, latest.vers);
|
|
342
|
+
const pkgData = await client.download(name, latest.vers, latest.cksum);
|
|
343
343
|
await Bun.write(tmpPath, pkgData);
|
|
344
344
|
} catch (err) {
|
|
345
345
|
return {
|