@celilo/cli 1.13.0 → 2.0.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 +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -59,7 +59,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
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/`.
|
|
61
61
|
- **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
|
|
62
|
-
- **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **
|
|
62
|
+
- **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Deployable, not yet servable.** The manifest, the SPA, the console server, the read verbs, the `control_plane_api` capability, the `on_install` that enrols the principal, and a full deployable body (`terraform/`, `ansible/` with a role, config variables, and a `build:` step compiling a per-architecture bun binary plus the SPA) all exist. What does not exist is an OIDC client: the console refuses to serve without an identity provider, and nothing creates one yet, so a deployed unit starts, fails its start limit and sits in `failed` with the reason in its journal. That is deliberate rather than hidden — leaving the service disabled would make an unfinished module look like a clean converge. The e2e suite does not exist either. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
|
|
63
63
|
- **celilo-canary** — a deliberately minimal nginx serving one static page in **`dmz`**, permanently deployed, whose health check IS the assertion that the deploy path still works. Nothing about it is interesting except that it is *always there*: modules already deployed keep running when the pipeline breaks, so without a canary a regression in IPAM allocation, Terraform provisioning, Ansible convergence or capability wiring stays invisible until the next real deploy — which is exactly when nobody wants to discover it. One deploy exercises all four, plus a live cross-module capability call. **Fleet-only, and the absence is the design**: it registers one route through `private_web` and requires nothing that reaches the perimeter, so there is no public record, no ACME certificate and no port forward. Its `health_check` probes nginx over systemd and its `/healthz` endpoint with **`probeHttp`, from the management server** rather than by SSHing in and running `curl` — the production `ubuntu-22.04-standard` LXC ships no curl, so the old form could not tell a missing binary from a dead service, and reaching the canary at its own address additionally catches a service bound only to loopback. **requires:** `private_web`.
|
|
64
64
|
|
|
65
65
|
## Git forge & CI pipeline
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -93,6 +93,26 @@ content module targets either with
|
|
|
93
93
|
`capabilities.external_web ?? capabilities.public_web`. Both are
|
|
94
94
|
**authoritative**: a publish that doesn't reach clients throws (#328).
|
|
95
95
|
|
|
96
|
+
- **Static-content converge (`/srv/www`, design D10)** —
|
|
97
|
+
`apps/celilo/src/services/static-content-converge.ts` (pure
|
|
98
|
+
`planStaticContent`/`buildStaticContentPlan` + `writeStaticContentVars` + the
|
|
99
|
+
`executeAnsible` caller `convergeStaticContent`). The declared `web_routes`
|
|
100
|
+
release set (slug, content hash, hostnames, the source dir core derives from
|
|
101
|
+
`modules.source_path`) is the desired state; the provider's Ansible converge
|
|
102
|
+
makes `/srv/www` match: content-hashed release dirs `/srv/www/<slug>-<hash>`,
|
|
103
|
+
an atomic symlink swap of `/srv/www/<slug>`, pruning beyond
|
|
104
|
+
`static_release_retention` (caddy manifest variable, operator config with
|
|
105
|
+
default 5 — the role never carries the number). Two callers, one plan: the
|
|
106
|
+
`public_web` capability's `convergeStaticContent` callback (tag-scoped run
|
|
107
|
+
through `executeAnsible`, so a publish returns only once the host matches)
|
|
108
|
+
and the provider's own deploy (vars written pre-Ansible in
|
|
109
|
+
`module-deploy.ts`, so a rebuilt host recovers with no consumer). The
|
|
110
|
+
content hash lands on EVERY route row sharing the slug. Replaced the
|
|
111
|
+
hand-built ssh tar pipe in `upload_static_assets` (celilo#1014); the
|
|
112
|
+
no-hand-built-SSH gate now scans `@celilo/capabilities` everywhere except
|
|
113
|
+
its primitive file (`module-script-scan.ts`, `scanCapabilityPackageSource`).
|
|
114
|
+
e2e: `modules/caddy/e2e/static-content-converge.test.ts`.
|
|
115
|
+
|
|
96
116
|
## Remote-ops primitives (the SSH seam — modules never hand-build SSH)
|
|
97
117
|
|
|
98
118
|
Module hooks reach a remote box ONLY through these typed primitives
|
|
@@ -180,8 +200,10 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
180
200
|
## Hooks & deploy
|
|
181
201
|
|
|
182
202
|
- **Hook executor / ABI** — `apps/celilo/src/hooks/executor.ts` (`invokeHook`, `executeHookScript`, `checkRequiredCapabilities`), types in `apps/celilo/src/hooks/types.ts` (`HookContext`, `HookDefinition`, `HookName`). Named-hook runner: `apps/celilo/src/hooks/run-named-hook.ts`. Manifest hook config: `apps/celilo/src/hooks/load-hook-config.ts`.
|
|
183
|
-
- **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code). `executeHookScript` spawns `bun hook-runner.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`, `CELILO_HOOK_
|
|
184
|
-
- **
|
|
203
|
+
- **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner-entry.ts` + `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code; the entry exists to install the advisory lint before the shim's import graph can ESM-load `node:fs`, see `unjailed-lint.ts`). `executeHookScript` spawns `bun hook-runner-entry.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — how to run: `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`; whom to trust: `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`; the proxy variables; the `CELILO_HOOK_*` channels; `CELILO_DEBUG` — `FORWARDED_ENV` in executor.ts is the source of truth), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stages 1 and 2 of `openspec/changes/hook-process-boundary`; the filesystem is claimed by the jail below, and SSH reachability by the remote-ops broker next.
|
|
204
|
+
- **Unjailed advisory lint (task 4.7, NOT a security boundary)** — `apps/celilo/src/hooks/unjailed-lint.ts`. Where there is no jail backend the executor passes the run's derived mount set to the shim in the environment (`CELILO_HOOK_MOUNT_SET`), and the shim wraps the path-taking `node:fs` / `node:fs/promises` functions so an access outside the set (or a write to a read-only row) warns through the hook's own logger: "on a jailed host this would fail", and that it is advisory. It is the mount-set derivation's SECOND consumer, so it cannot drift from what the jail enforces. It observes JS-level `node:fs` calls only — module code bypasses it trivially — and it must never be described as a boundary, in code or output.
|
|
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
|
+
- **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.
|
|
185
207
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
186
208
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
187
209
|
- **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:
|
|
@@ -263,7 +285,7 @@ is currently wrong, and routes carry the message to a person's phone. Design:
|
|
|
263
285
|
|
|
264
286
|
- **Alert identity** — `apps/celilo/src/services/alerting/keys.ts` — the key grammar (`module:<id>[/check:<name>]`, `builtin:<check>[/<kind>:<target>]`) that makes "the same problem" the same alert across runs. `moduleAlertKey`, `moduleCheckAlertKey`, `builtinAlertKey`, `parseAlertKey`.
|
|
265
287
|
- **Reconciliation** — `apps/celilo/src/services/alerting/reconcile.ts` (`reconcile`) — a successful run's failing-key set is authoritative and resolution is by SET DIFFERENCE (absent ⇒ resolved). A run whose outcome is `error` resolves NOTHING and fires a module-level alert instead: the false-all-clear guard.
|
|
266
|
-
- **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` (the built-in checks
|
|
288
|
+
- **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` / `hook-jail.ts` (the built-in checks, the "module with no health check" coverage check, and the hook-jail regression self-monitor).
|
|
267
289
|
- **Health-check cadence (one accessor)** — `apps/celilo/src/services/alerting/health-cadence.ts` — `effectiveHealthCheckCadence(manifest, override)`, `isScheduled`, `loadModuleHealthCadences`, `reconcileModuleWatchState`. The manifest's `hooks.health_check.interval` SUGGESTS; the operator's `health_check_interval` decides; both resolve at read time. `null` means nobody named a cadence (a coverage gap); `'manual'` means the operator opted out (a decision — raises no coverage finding, and resolves the module's live alerts on the way down, since nothing will report on them again).
|
|
268
290
|
- **⚠️ `monitors.intervalMinutes` and `monitors.enabled` are `builtin_check`-only.** A `module_hook` row carries severity, escalation policy and `lastRunAt`; its cadence and whether it is watched resolve through the accessor above. The columns' meaning depending on `kind` is a named smell (design.md D8) — the alternatives are a cached resolved value that rots, or splitting the table, which needs a synthetic monitor identity for `alerts.monitorId`. Gates: `sweep-runner.test.ts` asserts a module row's stored values are NOT consulted; `cadence-migration.test.ts` asserts the same through `loadModuleHealthCadences`.
|
|
269
291
|
- **Carrying an existing fleet over** — `apps/celilo/src/services/alerting/cadence-migration.ts` (`migrateMonitorCadences`), run from `celilo system migrate` (the `.deb` postinst runs it on every apt upgrade). A monitor row whose cadence diverges from its manifest gets that cadence written as an override; a disabled one gets `manual`. Not bookkeeping: without it the upgrade that ships read-time resolution silently reverts every hand-set cadence to the author's suggestion and resumes watching modules an operator deliberately disabled. Idempotent — writes only where no override exists.
|
|
@@ -290,6 +312,8 @@ SUGGESTS a `backup.schedule`; the operator's override decides; celilo runs it on
|
|
|
290
312
|
the resolved cadence and alerts when it stops.
|
|
291
313
|
|
|
292
314
|
- **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules` (returns each module's operator config alongside its manifest, because every caller has to resolve a policy out of the two together), `isBackupDue`. Storage destinations: `backup-storage.ts`. Restore: `backup-restore.ts`.
|
|
315
|
+
- **Staging celilo's own state** — `apps/celilo/src/services/system-state-stage.ts` — `stageSystemState(rootDir)` and `snapshotDatabase(src, dest)`. Before invoking `on_backup` on a `cross_module_read` module, celilo COPIES its own state (`celilo.db` snapshot, `master.key`, the fleet `ssh/`, and `module_src/<id>/` for every module's lean source) into a directory it creates, and passes the path as the contract input `system_state_root`. ⚠️ **This is what lets celilo back ITSELF up without exempting celilo-mgmt from the hook jail** (`openspec/changes/hook-process-boundary`, design D9b): the hook never READ those bytes, it copied them into `backup_dir`, so the framework does the copying and celilo's data directory is in no hook's mount set. Same allow-list as `cross_module_root` — one privilege, one list to audit. ⚠️ **`snapshotDatabase` uses a readonly connection + `serialize()`, never `copyFileSync`**: celilo runs the DB in WAL mode, so the main file is routinely one near-empty page while all the real data sits in `celilo.db-wal` (measured: 4 KB main against 832 KB WAL), and a plain copy produces a snapshot that opens cleanly, contains NOTHING, and is installed by restore. Gate: `services/system-state-stage.test.ts` writes 200 uncheckpointed rows and asserts they survive.
|
|
316
|
+
- **The fleet SSH key (one accessor)** — `apps/celilo/src/services/fleet-key.ts` — `ensureFleetKey()` (idempotent mint, returns the public half) and `getFleetSshDir()`. Surfaced as `celilo system ensure-fleet-key`, which also records `ssh.public_key`. celilo-mgmt's `on_install` used to mint the keypair itself inside celilo's data directory — a WRITE into the one directory the jail exists to keep out of the mount set (design D9b), which staging does not cover because staging covers copies OUT. ⚠️ **Never re-key**: an existing key is reused, because regenerating strands every machine whose `authorized_keys` holds the old public half, and a redeploy calls this every time. `getFleetSshDir()` follows the DB (`dirname(getDbPath())/.ssh`), not `getDataDir()` — the same directory on a deb install and different when `CELILO_DB_PATH` is overridden; both the mint and `restore-from-file.ts`'s laydown read that one helper so they cannot drift apart.
|
|
293
317
|
- **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest, override)`. The manifest SUGGESTS; the operator's `backup_schedule` row in `module_configs` decides; absent from both means `daily`, NOT `manual` (opting out takes an explicit `manual`). Resolution happens at READ time — nothing is materialised at install or deploy — so a corrected manifest reaches every install that has not overridden. The one-argument form was DELETED rather than kept as an overload (Rule 3.9): it would let an un-updated reader compile clean while silently ignoring overrides. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
|
|
294
318
|
- **The cadence type** — `apps/celilo/src/services/cadence.ts` — `Cadence` (`{minutes}` | `'manual'`), `parseCadence` / `formatCadence` / `cadenceMs`, and `cadenceSchema({floorMinutes})`. One spelling set for every cadence in celilo: a named period (`hourly`/`daily`/`weekly`/`monthly`), a duration (`6h`, `90m`, `3d`), or `manual`. ⚠️ **The floors are DERIVED from the sweep ticks, never written down**: this file owns `BACKUP_SWEEP_PATTERN` / `ALERTING_SWEEP_PATTERN` and computes `BACKUP_CADENCE_FLOOR_MINUTES` / `MONITOR_INTERVAL_FLOOR_MINUTES` from them (the sweeps import their pattern from here), so changing a tick moves what it can serve in the same edit. A cadence finer than its sweep's tick is REFUSED, not coerced — accepting it leaves the operator believing they configured something that can silently never happen.
|
|
295
319
|
- **Retention (one accessor)** — `apps/celilo/src/services/backup-retention.ts` — `effectiveBackupRetention(manifest, configs)`, `prunesNothing`, `identifyExpiredBackups`, `pruneBackupsForModule`. Two INDEPENDENT dimensions (copies, age), each resolved override → manifest → **unbounded**. ⚠️ **An unset dimension is unbounded, never a default bound.** `backup.retention` is an optional block, so a manifest omitting it prunes nothing at all and its inner `count: 7` / `max_age_days: 30` defaults never apply; if setting one dimension let the other fall back to those, an operator asking to keep 3 copies would silently arm a 30-day deletion on a module that had been keeping everything. Unbounded is `Infinity`, which `identifyExpiredBackups` needs no special case for. Gate: `services/backup-retention.test.ts`. The four sites that used to read `manifest.backup.retention` directly (`cli/commands/backup-sweep.ts`, `backup-create.ts`, `backup-prune.ts` twice) all go through the accessor — that duplication is what let the schedule readers drift.
|
|
@@ -346,6 +370,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
346
370
|
- **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
|
|
347
371
|
- **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via the `@celilo/cli-display` prompts). Refuses to prompt on a non-TTY stdin, replying `unanswerable` rather than submitting a default as if a human had chosen it.
|
|
348
372
|
- **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
|
|
373
|
+
- **Principal enrolment for a module (`control_plane_api`)** — `apps/celilo/src/services/api-principal-enrolment.ts` — `enrolControlPlanePrincipal`, `revokeControlPlanePrincipal`, and `buildControlPlaneApi`, the method table a consuming module's hooks receive. The consumer generates an ed25519 pair on its own system and presents the public half; nothing here accepts a private key. Grants are DERIVED from `readOnlyGrants(COMMANDS)` and are not a parameter, so a caller cannot ask for more, and a write verb is never granted however it is named. **Framework-granted, so no module provides it** — enrolment writes celilo's own `api_principals` row and a module script may import nothing but `@celilo/capabilities`, which rules out celilo-mgmt as much as anyone else (`web-ui-console` D7b). Injected by `capability-loader.ts` ONLY for a module whose stored manifest declares it under `requires`/`optional`, unlike every other capability the loader hands out, and scoped to that module: a caller may not name a neighbour's principal. Contract: `packages/capabilities/src/control-plane-api.ts`.
|
|
349
374
|
- **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
|
|
350
375
|
- **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
|
|
351
376
|
- **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100), then `celilo events restart-daemon` so the dispatcher actually runs the code just installed — a failure there fails the whole command and names which steps DID complete, because "upgraded" while the dispatcher serves stale code is the silent state celilo#604 documents. It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
|
|
@@ -354,14 +379,15 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
354
379
|
|
|
355
380
|
## Web console (read-mostly operator UI)
|
|
356
381
|
|
|
357
|
-
The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server
|
|
382
|
+
The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server, the console read verbs, the acknowledgement path and the `control_plane_api` capability exist; the module's `on_install` that CALLS that capability, and the e2e suite, do not. The capability issues only the derived read-only grants and has no parameter that could widen them (task 6.3b settled that deliberately), so acknowledgement renders a denial until an operator grants `alerts:ack` by hand.
|
|
358
383
|
|
|
359
384
|
- **Console read verbs** — `apps/celilo/src/cli/commands/console.ts` — `celilo console status` (the dashboard's single poll: zone order, per-module systems, observed health, backup freshness) and `celilo console get <module-id> [--depth N]` (one module's bounded capability closure). Both classify **read-only** under `readOnlyGrants()`, so the console's principal covers them with no hand-maintained list. The verbs are named from the read-verb vocabulary (`status`, `get`) rather than for prose, because the API authorises at two levels and classifies a leaf by its SUBCOMMAND token — `console roster` would read better and classify as a WRITE.
|
|
360
385
|
- **Console projection** — `apps/celilo/src/console/projection.ts` — the narrow payload, deliberately WITHOUT `manifestData`: `module list --json` returns 156 KB for 23 modules because it embeds every manifest blob, which is the wrong payload for a poll loop. Distinguishes *not deployed* from *not observed*; a module with no system is carried, not omitted.
|
|
361
386
|
- **Bounded capability closure** — `apps/celilo/src/console/closure.ts` — `computeClosure()` wraps `planConsumerCleanup()`'s edge rather than forking it, adding distance, optionality and cycle termination. Takes a bindings map (`capability_bindings`, celilo#1072) so the walk follows what a module has ACTUALLY called into; without one it follows declarations, which answers "what could this reach" rather than "what does this stand on".
|
|
362
387
|
- **Read-only classifier** — `packages/core/src/read-only-classifier.ts` — `READ_VERBS`, `isReadOnlyPath`, `opOf`, `flattenLeaves`, `readOnlyGrants()`. Lives beside `COMMANDS` because it is a property of the registry, and both the MCP server's `RO_GRANTS` and the console's principal derive from it. A new read verb is covered automatically; a new write verb is not.
|
|
363
388
|
- **SSH transport** — `packages/core/src/ssh-transport.ts` — `childProcessTransport`, `sshArgs`, `sshTransportWith`, and the exit reaper. Shared rather than copied because the reaper exists after 656 orphaned ssh clients threw celilo-mgr into MaxStartups throttling (celilo#921), and the console server has the same restart-freely lifecycle.
|
|
364
|
-
- **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a read
|
|
389
|
+
- **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a principal granted the derived read ops plus `alerts:ack`. An interview is a FAILURE (a browser has no responder), and an unavailable read is reported with a reason (`denied` / `unknown-verb` / `unreachable` / `interview` / `malformed`) rather than returned as an empty result. Failures are not cached, so fixing a grant recovers on the next poll. `Upstream.run` is the one non-read: uncached, unparsed (the ack answers with a sentence), and it drops every cached read afterwards.
|
|
390
|
+
- **Console acknowledgement** — `apps/console-server/src/verbs.ts` (`readSession`, `ackAlert`), `apps/celilo/src/cli/commands/notify-config.ts` (`celilo person list --json`) — the console's ONLY write. `celilo alerts ack` falls back to `people[0]` when `--as` is absent, which in a browser would credit every acknowledgement to whoever sorts first, so `ackAlert` takes a required `person` and there is no path through it that omits the flag. The person comes from `readSession`, which resolves the identity provider's subject against `person list --json` (display name, then the `sub` claim, case-insensitive); a subject matching nobody resolves to null and the console draws no control. `ackedAt` is re-read from celilo's row rather than stamped by the console server, which is a different machine. Gates: `apps/console-server/tests/ack.test.ts` records the argv, so "refused but written anyway" is visible.
|
|
365
391
|
- **Protocol** — `packages/console-protocol/` — tsrpc definitions plus the generated `serviceProto`, shared by server and SPA. The repo's single documented exception to ESM-everywhere: no `type` field, because `tsrpc-cli proto` `require()`s the protocol sources. Gated by a test in an ESM directory that round-trips a call.
|
|
366
392
|
- **SPA** — `apps/console/src/` — Vite + React 19 + atom.io + tsrpc-browser. Three routes (`dashboard`, `alerts`, `backups`); a module's detail is a panel, not a fourth route. Visual constraints live in `shell.css` and four are asserted against the source by `tests/constraints.test.ts`, including "no control for any operation that can raise an interview". Every read goes through one `Panel` that separates *unavailable* from *empty*.
|
|
367
393
|
- **Zone topology renderer** — `packages/visualizer/src/gen/zone-topology.ts`, `topology-theme.ts`, `column-assignment.ts`, `render/topology-svg.ts` — bands by zone in trust order, columns assigned so a dependency edge falls as a vertical drop, orthogonal routing checked against every module rather than assumed clear, and a report naming anything it could not place. Tuning playground at `bun run dev` in `packages/visualizer`, route `/topology`; it exports a `TopologyTheme` JSON the console commits.
|
package/README.md
CHANGED
|
@@ -286,7 +286,6 @@ bun run test:integration # All integration tests (run via bun test)
|
|
|
286
286
|
# - VM resource defaults
|
|
287
287
|
|
|
288
288
|
# What gets skipped:
|
|
289
|
-
# - Nix builds
|
|
290
289
|
# - Docker builds
|
|
291
290
|
# - Slow module builds (Caddy)
|
|
292
291
|
```
|
|
@@ -296,7 +295,6 @@ bun run test:integration # All integration tests (run via bun test)
|
|
|
296
295
|
bun run test:integration-slow # Full builds + end-to-end
|
|
297
296
|
|
|
298
297
|
# Includes everything from fast tests PLUS:
|
|
299
|
-
# - Full Nix builds (Caddy with modules)
|
|
300
298
|
# - Docker builds
|
|
301
299
|
# - Complete module packaging
|
|
302
300
|
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- The `environment` column recorded whether a build ran inside a `nix develop`
|
|
2
|
+
-- shell ('nix') or with system tools ('system'). That was the only information
|
|
3
|
+
-- it carried, and the Nix build path never shipped: no module has ever carried
|
|
4
|
+
-- a flake.nix, and the detection code (detectNixEnvironment / isNixAvailable /
|
|
5
|
+
-- the `nix develop` wrapper in module-build.ts) is deleted in the same change.
|
|
6
|
+
-- Every row ever written holds 'system' or NULL, so a constant column: deleted
|
|
7
|
+
-- with the path it described rather than kept as dead weight.
|
|
8
|
+
ALTER TABLE `module_builds` DROP COLUMN `environment`;
|
|
@@ -211,6 +211,13 @@
|
|
|
211
211
|
"when": 1784400000000,
|
|
212
212
|
"tag": "0029_module_instances",
|
|
213
213
|
"breakpoints": true
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"idx": 30,
|
|
217
|
+
"version": "6",
|
|
218
|
+
"when": 1784400000000,
|
|
219
|
+
"tag": "0030_drop_module_builds_environment",
|
|
220
|
+
"breakpoints": true
|
|
214
221
|
}
|
|
215
222
|
]
|
|
216
|
-
}
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^
|
|
61
|
+
"@celilo/capabilities": "^4.0.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.
|
|
63
|
+
"@celilo/core": "^0.11.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -89,7 +89,6 @@ function basePublishRequest(
|
|
|
89
89
|
): PublishStaticSiteRequest {
|
|
90
90
|
return {
|
|
91
91
|
path: '/lunacycle',
|
|
92
|
-
sourceDir: '/tmp/build/dist',
|
|
93
92
|
...overrides,
|
|
94
93
|
};
|
|
95
94
|
}
|
|
@@ -134,17 +133,24 @@ describe('validatePublishStaticSiteRequest', () => {
|
|
|
134
133
|
expect(result.valid).toBe(true);
|
|
135
134
|
});
|
|
136
135
|
|
|
137
|
-
test('rejects
|
|
138
|
-
|
|
136
|
+
test('rejects a stale sourceDir, and says what to do instead', () => {
|
|
137
|
+
// Replaces 'rejects empty sourceDir'. The field is gone (D10 amendment), so
|
|
138
|
+
// the failure mode inverted: supplying one is now the error, and the
|
|
139
|
+
// message has to carry the directory move because that is not guessable
|
|
140
|
+
// from "unknown key".
|
|
141
|
+
const result = validatePublishStaticSiteRequest({
|
|
142
|
+
path: '/lunacycle',
|
|
143
|
+
sourceDir: '/tmp/site',
|
|
144
|
+
} as unknown as PublishStaticSiteRequest);
|
|
139
145
|
expect(result.valid).toBe(false);
|
|
140
|
-
expect(result.errors).toContain('
|
|
146
|
+
expect(result.errors.join(' ')).toContain('site/dist');
|
|
141
147
|
});
|
|
142
148
|
|
|
143
149
|
test('reports multiple errors at once', () => {
|
|
144
150
|
const result = validatePublishStaticSiteRequest({
|
|
145
151
|
path: '',
|
|
146
|
-
sourceDir: '',
|
|
147
|
-
});
|
|
152
|
+
sourceDir: '/tmp/site',
|
|
153
|
+
} as unknown as PublishStaticSiteRequest);
|
|
148
154
|
expect(result.valid).toBe(false);
|
|
149
155
|
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
|
150
156
|
});
|
|
@@ -106,6 +106,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
106
106
|
test('writes config.js into sourceDir before upload when clientConfig is provided', async () => {
|
|
107
107
|
const { ops } = makeRouteOps();
|
|
108
108
|
const cap = createPublicWeb({
|
|
109
|
+
webRoot: sourceDir,
|
|
109
110
|
moduleId: 'lunacycle',
|
|
110
111
|
logger: noopLogger,
|
|
111
112
|
config: {
|
|
@@ -115,6 +116,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
115
116
|
email: 'admin@example.com',
|
|
116
117
|
},
|
|
117
118
|
secrets: {},
|
|
119
|
+
convergeStaticContent: async () => {},
|
|
118
120
|
routeOps: ops,
|
|
119
121
|
hostnames: ['www.example.com'],
|
|
120
122
|
caddyModuleId: 'caddy',
|
|
@@ -123,7 +125,6 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
123
125
|
|
|
124
126
|
const result = await cap.publishStaticSite({
|
|
125
127
|
path: '/lunacycle',
|
|
126
|
-
sourceDir,
|
|
127
128
|
clientConfig: {
|
|
128
129
|
AUTHENTIK_URL: 'https://auth.example.com/application/o',
|
|
129
130
|
CLIENT_ID: 'lunacycle-web',
|
|
@@ -150,6 +151,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
150
151
|
test('does not write config.js when clientConfig is omitted', async () => {
|
|
151
152
|
const { ops } = makeRouteOps();
|
|
152
153
|
const cap = createPublicWeb({
|
|
154
|
+
webRoot: sourceDir,
|
|
153
155
|
moduleId: 'lunacycle',
|
|
154
156
|
logger: noopLogger,
|
|
155
157
|
config: {
|
|
@@ -159,6 +161,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
159
161
|
email: 'admin@example.com',
|
|
160
162
|
},
|
|
161
163
|
secrets: {},
|
|
164
|
+
convergeStaticContent: async () => {},
|
|
162
165
|
routeOps: ops,
|
|
163
166
|
hostnames: ['www.example.com'],
|
|
164
167
|
caddyModuleId: 'caddy',
|
|
@@ -167,7 +170,6 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
167
170
|
|
|
168
171
|
await cap.publishStaticSite({
|
|
169
172
|
path: '/lunacycle',
|
|
170
|
-
sourceDir,
|
|
171
173
|
// no clientConfig
|
|
172
174
|
});
|
|
173
175
|
|
|
@@ -177,6 +179,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
177
179
|
test('registers the route as static and records moduleId/path', async () => {
|
|
178
180
|
const { ops, routes } = makeRouteOps();
|
|
179
181
|
const cap = createPublicWeb({
|
|
182
|
+
webRoot: sourceDir,
|
|
180
183
|
moduleId: 'lunacycle',
|
|
181
184
|
logger: noopLogger,
|
|
182
185
|
config: {
|
|
@@ -186,6 +189,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
186
189
|
email: 'admin@example.com',
|
|
187
190
|
},
|
|
188
191
|
secrets: {},
|
|
192
|
+
convergeStaticContent: async () => {},
|
|
189
193
|
routeOps: ops,
|
|
190
194
|
hostnames: ['www.example.com'],
|
|
191
195
|
caddyModuleId: 'caddy',
|
|
@@ -194,7 +198,6 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
194
198
|
|
|
195
199
|
await cap.publishStaticSite({
|
|
196
200
|
path: '/lunacycle',
|
|
197
|
-
sourceDir,
|
|
198
201
|
});
|
|
199
202
|
|
|
200
203
|
const stored = routes.find((r) => r.path === '/lunacycle');
|
|
@@ -204,9 +207,16 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
204
207
|
expect(stored?.slug).toBe('lunacycle');
|
|
205
208
|
});
|
|
206
209
|
|
|
207
|
-
test('fails fast when
|
|
210
|
+
test('fails fast, and names the path to ship, when the module has no web root', async () => {
|
|
211
|
+
// The publish path's one unrecoverable input. It used to arrive on the
|
|
212
|
+
// request as `sourceDir`; core now resolves it (D10 amendment), so the
|
|
213
|
+
// missing-directory case moves onto the capability's construction.
|
|
214
|
+
//
|
|
215
|
+
// It must throw rather than upload nothing: a zero-file upload succeeds,
|
|
216
|
+
// writes a content hash over an empty release, and serves a blank page.
|
|
208
217
|
const { ops } = makeRouteOps();
|
|
209
218
|
const cap = createPublicWeb({
|
|
219
|
+
webRoot: '/nonexistent/path/that/should/not/exist',
|
|
210
220
|
moduleId: 'lunacycle',
|
|
211
221
|
logger: noopLogger,
|
|
212
222
|
config: {
|
|
@@ -216,24 +226,26 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
216
226
|
email: 'admin@example.com',
|
|
217
227
|
},
|
|
218
228
|
secrets: {},
|
|
229
|
+
convergeStaticContent: async () => {},
|
|
219
230
|
routeOps: ops,
|
|
220
231
|
hostnames: ['www.example.com'],
|
|
221
232
|
caddyModuleId: 'caddy',
|
|
222
233
|
dnsManagedDomains: ['www.example.com'],
|
|
223
234
|
});
|
|
224
235
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
).rejects.toThrow('
|
|
236
|
+
const attempt = cap.publishStaticSite({
|
|
237
|
+
path: '/lunacycle',
|
|
238
|
+
clientConfig: { FOO: 'bar' },
|
|
239
|
+
});
|
|
240
|
+
await expect(attempt).rejects.toThrow('no built site at');
|
|
241
|
+
// The operator's next move is a directory move, so the error has to name it.
|
|
242
|
+
await expect(attempt).rejects.toThrow('site/dist');
|
|
232
243
|
});
|
|
233
244
|
|
|
234
245
|
test('fails validation before touching the filesystem', async () => {
|
|
235
246
|
const { ops } = makeRouteOps();
|
|
236
247
|
const cap = createPublicWeb({
|
|
248
|
+
webRoot: sourceDir,
|
|
237
249
|
moduleId: 'lunacycle',
|
|
238
250
|
logger: noopLogger,
|
|
239
251
|
config: {
|
|
@@ -243,6 +255,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
243
255
|
email: 'admin@example.com',
|
|
244
256
|
},
|
|
245
257
|
secrets: {},
|
|
258
|
+
convergeStaticContent: async () => {},
|
|
246
259
|
routeOps: ops,
|
|
247
260
|
hostnames: ['www.example.com'],
|
|
248
261
|
caddyModuleId: 'caddy',
|
|
@@ -252,7 +265,6 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
252
265
|
await expect(
|
|
253
266
|
cap.publishStaticSite({
|
|
254
267
|
path: '', // bad — caught by validator before any side effects
|
|
255
|
-
sourceDir,
|
|
256
268
|
}),
|
|
257
269
|
).rejects.toThrow('Invalid publishStaticSite request');
|
|
258
270
|
|
|
@@ -282,6 +294,7 @@ describe('registerReverseProxy', () => {
|
|
|
282
294
|
email: 'admin@example.com',
|
|
283
295
|
},
|
|
284
296
|
secrets: {},
|
|
297
|
+
convergeStaticContent: async () => {},
|
|
285
298
|
routeOps: ops,
|
|
286
299
|
hostnames: ['www.example.com'],
|
|
287
300
|
caddyModuleId: 'caddy',
|
|
@@ -318,6 +331,7 @@ describe('registerReverseProxy', () => {
|
|
|
318
331
|
email: 'admin@example.com',
|
|
319
332
|
},
|
|
320
333
|
secrets: {},
|
|
334
|
+
convergeStaticContent: async () => {},
|
|
321
335
|
routeOps: ops,
|
|
322
336
|
hostnames: ['www.example.com'],
|
|
323
337
|
caddyModuleId: 'caddy',
|
|
@@ -355,6 +369,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
355
369
|
const { ops } = makeRouteOps();
|
|
356
370
|
|
|
357
371
|
const cap = createPublicWeb({
|
|
372
|
+
webRoot: sourceDir,
|
|
358
373
|
moduleId: 'lunacycle',
|
|
359
374
|
logger,
|
|
360
375
|
config: {
|
|
@@ -364,6 +379,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
364
379
|
email: 'admin@example.com',
|
|
365
380
|
},
|
|
366
381
|
secrets: {},
|
|
382
|
+
convergeStaticContent: async () => {},
|
|
367
383
|
routeOps: ops,
|
|
368
384
|
hostnames: ['www.example.com'],
|
|
369
385
|
caddyModuleId: 'caddy',
|
|
@@ -389,6 +405,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
389
405
|
const { ops } = makeRouteOps();
|
|
390
406
|
|
|
391
407
|
const cap = createPublicWeb({
|
|
408
|
+
webRoot: sourceDir,
|
|
392
409
|
moduleId: 'lunacycle',
|
|
393
410
|
logger,
|
|
394
411
|
config: {
|
|
@@ -398,6 +415,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
398
415
|
email: 'admin@example.com',
|
|
399
416
|
},
|
|
400
417
|
secrets: {},
|
|
418
|
+
convergeStaticContent: async () => {},
|
|
401
419
|
routeOps: ops,
|
|
402
420
|
hostnames: ['www.example.com'],
|
|
403
421
|
caddyModuleId: 'caddy',
|
|
@@ -424,6 +442,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
424
442
|
const { ops } = makeRouteOps();
|
|
425
443
|
|
|
426
444
|
const cap = createPublicWeb({
|
|
445
|
+
webRoot: sourceDir,
|
|
427
446
|
moduleId: 'lunacycle',
|
|
428
447
|
logger,
|
|
429
448
|
config: {
|
|
@@ -433,13 +452,14 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
433
452
|
email: 'admin@example.com',
|
|
434
453
|
},
|
|
435
454
|
secrets: {},
|
|
455
|
+
convergeStaticContent: async () => {},
|
|
436
456
|
routeOps: ops,
|
|
437
457
|
hostnames: ['www.example.com'],
|
|
438
458
|
caddyModuleId: 'caddy',
|
|
439
459
|
dnsManagedDomains: ['www.example.com'],
|
|
440
460
|
});
|
|
441
461
|
|
|
442
|
-
await cap.publishStaticSite({ path: '/lunacycle'
|
|
462
|
+
await cap.publishStaticSite({ path: '/lunacycle' });
|
|
443
463
|
|
|
444
464
|
const messageTexts = messages.map((m) => m.message);
|
|
445
465
|
// The high-level call itself logs.
|
|
@@ -459,6 +479,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
459
479
|
const { ops } = makeRouteOps();
|
|
460
480
|
|
|
461
481
|
const cap = createPublicWeb({
|
|
482
|
+
webRoot: sourceDir,
|
|
462
483
|
moduleId: 'lunacycle',
|
|
463
484
|
logger,
|
|
464
485
|
config: {
|
|
@@ -468,6 +489,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
468
489
|
email: 'admin@example.com',
|
|
469
490
|
},
|
|
470
491
|
secrets: {},
|
|
492
|
+
convergeStaticContent: async () => {},
|
|
471
493
|
routeOps: ops,
|
|
472
494
|
hostnames: ['www.example.com'],
|
|
473
495
|
caddyModuleId: 'caddy',
|
|
@@ -524,6 +546,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
524
546
|
logger: noopLogger,
|
|
525
547
|
config: { target_ip: '10.0.10.20/24' },
|
|
526
548
|
secrets: {},
|
|
549
|
+
convergeStaticContent: async () => {},
|
|
527
550
|
routeOps: ops,
|
|
528
551
|
hostnames: ['www.example.com'],
|
|
529
552
|
caddyModuleId: 'caddy',
|
|
@@ -551,6 +574,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
551
574
|
logger: noopLogger,
|
|
552
575
|
config: { target_ip: '10.0.10.20/24' },
|
|
553
576
|
secrets: {},
|
|
577
|
+
convergeStaticContent: async () => {},
|
|
554
578
|
routeOps: ops,
|
|
555
579
|
hostnames: ['www.example.com'],
|
|
556
580
|
caddyModuleId: 'caddy',
|
|
@@ -582,6 +606,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
582
606
|
logger: noopLogger,
|
|
583
607
|
config: { target_ip: '10.0.10.20/24' },
|
|
584
608
|
secrets: {},
|
|
609
|
+
convergeStaticContent: async () => {},
|
|
585
610
|
routeOps: ops,
|
|
586
611
|
hostnames: ['www.example.com'],
|
|
587
612
|
caddyModuleId: 'caddy',
|
|
@@ -602,6 +627,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
602
627
|
logger: noopLogger,
|
|
603
628
|
config: { target_ip: '10.0.10.20/24' },
|
|
604
629
|
secrets: {},
|
|
630
|
+
convergeStaticContent: async () => {},
|
|
605
631
|
routeOps: ops,
|
|
606
632
|
hostnames: ['www.example.com'],
|
|
607
633
|
caddyModuleId: 'caddy',
|
|
@@ -626,6 +652,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
626
652
|
logger: noopLogger,
|
|
627
653
|
config: { target_ip: '10.0.10.20/24' },
|
|
628
654
|
secrets: {},
|
|
655
|
+
convergeStaticContent: async () => {},
|
|
629
656
|
routeOps: ops,
|
|
630
657
|
hostnames: ['www.example.com'],
|
|
631
658
|
caddyModuleId: 'caddy',
|
|
@@ -652,6 +679,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
652
679
|
// No target_ip and no firewallNatIp → internalDnsIp is falsy.
|
|
653
680
|
config: {},
|
|
654
681
|
secrets: {},
|
|
682
|
+
convergeStaticContent: async () => {},
|
|
655
683
|
routeOps: ops,
|
|
656
684
|
hostnames: ['www.example.com'],
|
|
657
685
|
caddyModuleId: 'caddy',
|
|
@@ -682,6 +710,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
682
710
|
logger: noopLogger,
|
|
683
711
|
config: { target_ip: '10.0.10.20/24' },
|
|
684
712
|
secrets: {},
|
|
713
|
+
convergeStaticContent: async () => {},
|
|
685
714
|
routeOps: ops,
|
|
686
715
|
hostnames: ['www.example.com'],
|
|
687
716
|
caddyModuleId: 'caddy',
|
|
@@ -228,6 +228,37 @@ describe('Capability Access Validation', () => {
|
|
|
228
228
|
expect(result.success).toBe(true);
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
test('a framework-granted capability needs no providing module', async () => {
|
|
232
|
+
// This is the breakage, not a hypothetical. `control_plane_api` has no
|
|
233
|
+
// provider module and never will — celilo grants it (web-ui-console D7b) —
|
|
234
|
+
// so the console's manifest, already merged, hit the refusal below and
|
|
235
|
+
// could not be imported at all. Note the db here is the SAME one that
|
|
236
|
+
// fails the test after this one: the difference is entirely which
|
|
237
|
+
// capability is asked for.
|
|
238
|
+
const manifest: ModuleManifest = {
|
|
239
|
+
celilo_contract: '1.0',
|
|
240
|
+
id: 'celilo-web-console',
|
|
241
|
+
name: 'Celilo Web Console',
|
|
242
|
+
version: '1.0.0',
|
|
243
|
+
description: 'Console',
|
|
244
|
+
requires: {
|
|
245
|
+
capabilities: [{ name: 'control_plane_api', version: '1.0.0' }],
|
|
246
|
+
},
|
|
247
|
+
provides: { capabilities: [] },
|
|
248
|
+
variables: { owns: [], imports: [] },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const noProviderDb = {
|
|
252
|
+
prepare: () => ({
|
|
253
|
+
get: () => undefined,
|
|
254
|
+
}),
|
|
255
|
+
} as unknown as Database;
|
|
256
|
+
|
|
257
|
+
const result = await validateCapabilityAccess(manifest, noProviderDb);
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
231
262
|
test('should return error when required capability not found', async () => {
|
|
232
263
|
const manifest: ModuleManifest = {
|
|
233
264
|
celilo_contract: '1.0',
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* report counts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { hostname } from 'node:os';
|
|
13
14
|
import { getDb } from '../../db/client';
|
|
14
15
|
import { modules } from '../../db/schema';
|
|
16
|
+
import { readJailMode } from '../../hooks/jail';
|
|
15
17
|
import { runBuiltinCheckForMonitor } from '../../services/alerting/builtin-source';
|
|
16
18
|
import { loadModuleCoverage } from '../../services/alerting/coverage-source';
|
|
17
19
|
import { modulesInDeployWindow } from '../../services/alerting/deploy-hooks';
|
|
@@ -61,6 +63,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
61
63
|
runModuleHealthCheck(moduleId, db, { unattended: true, noInteractive: true }),
|
|
62
64
|
runBuiltinCheck: (category) => runBuiltinCheckForMonitor(category, db),
|
|
63
65
|
loadModuleCoverage: () => loadModuleCoverage(db),
|
|
66
|
+
loadJailState: () => ({ record: readJailMode(), host: hostname() }),
|
|
64
67
|
now: () => new Date(),
|
|
65
68
|
graceMs: DEFAULT_GRACE_MS,
|
|
66
69
|
},
|