@celilo/cli 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +17 -2
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console.ts +122 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/completion.ts +5 -0
- package/src/cli/index.ts +25 -1
- package/src/console/closure.test.ts +246 -0
- package/src/console/closure.ts +208 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +231 -0
- package/src/console/projection.ts +327 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +101 -0
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +12 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +179 -0
- package/src/services/api-principal-enrolment.ts +103 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -55,10 +55,11 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
55
55
|
## Celilo's own infrastructure (self-hosted)
|
|
56
56
|
|
|
57
57
|
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations, self-registers). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
|
|
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. **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
|
|
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/`.
|
|
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. **Not yet deployed** — the manifest, the SPA, the console server and the read verbs exist; the `control_plane_api` enrolment and the e2e suite do not. 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/`.
|
|
62
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`.
|
|
63
64
|
|
|
64
65
|
## Git forge & CI pipeline
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -45,7 +45,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
45
45
|
## Capability system (cross-module data & functions)
|
|
46
46
|
|
|
47
47
|
- **Define a capability function** — `packages/capabilities/src/define-capability-function.ts` — `defineCapabilityFunction`.
|
|
48
|
-
- **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Public surface: `packages/capabilities/src/index.ts`.
|
|
48
|
+
- **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Also `PROVIDER_VIEW_CAPABILITIES` / `isProviderView` / `ProviderViewCapabilities`: the registry entries (`web_routes`, `firewall_registry`) that are framework-injected PROVIDER VIEWS rather than capabilities a module can declare — in the registry so audits and type checks reach them, named separately so author-facing surfaces leave them out. `ProviderViewCapabilities` is what puts them on a hook's `capabilities` object. Public surface: `packages/capabilities/src/index.ts`.
|
|
49
49
|
- **Loader (wires provider factories into hook contexts)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadCapabilityFunctions`, `resolveFirewallNatIp`.
|
|
50
50
|
- **Which provider a consumer actually resolved to (`capability_bindings`)** — `apps/celilo/src/services/capability-bindings.ts` — `recordCapabilityBinding`, `listCapabilityBindings`, `withBindingRecord`, written from the single return of `loadCapabilityFunctions`. The `capabilities` table is provider-side only, so celilo could answer "who COULD provide this" and never "who does this module actually use"; every caller needing the second question rebuilt the same approximation (the consumer's `requires` + `optional` crossed with `capabilities`), which names every provider a module MIGHT have bound to. `tango-nexus` declares four optional capabilities, is deployed against one off-fleet host, and that approximation carries three edges that do not exist. **The CALL is the binding, not the resolution** — the loader injects every registered capability regardless of what the consumer declared (its own comment: "not just required ones"), so recording what it resolves is a SUPERSET of even the permissive set. A consumer's hook invoking a method is the only event that separates the optional it uses from the ones it merely declares, which is why this matters most for the capabilities that mint nothing (`external_web`, `idp`, `source_forge`, `registry_publish`, `control_plane_vpn`, `apt_publish`) and therefore leave no artefact anywhere else. Recorded on ATTEMPT rather than success: a consumer that called a provider and got an error is standing on that provider, and hiding the edge exactly when the call fails hides it when it matters most. Unique on (consumer, capability) so a redeploy re-asserts and a provider swap rewrites in place; `bound_at` is LAST-SEEN, so a binding last exercised forty deploys ago is a queryable fact rather than a silent lie. Cascades on the consumer. It runs BESIDE `planConsumerCleanup`, never narrowing it — cleanup must still reach every provider that might hold minted state, including one whose binding row was never written. See celilo#1072.
|
|
51
51
|
- **Ledger wrappers (stateful capabilities)** — `apps/celilo/src/services/dns-registrations.ts` (`withDnsRegistrationLedger`), `apps/celilo/src/services/dns-internal-records.ts` (`withDnsInternalLedger`).
|
|
@@ -180,7 +180,8 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
180
180
|
## Hooks & deploy
|
|
181
181
|
|
|
182
182
|
- **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_*`, `CELILO_DEBUG`), 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.
|
|
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_*`, `CELILO_DEBUG`), 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, the network is not claimed at all (stage 3, D12).
|
|
184
|
+
- **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, `~/.ssh` read-only (stage 2 only — stage 3 drops it, D12), and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. **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`. No backend on macOS yet (task 4.8), and the hook then runs unjailed with the mode recorded.
|
|
184
185
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
185
186
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
186
187
|
- **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:
|
|
@@ -351,6 +352,20 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
351
352
|
- **Module auto-upgrade (registry-poll CD)** — the *pull* half of continuous deployment: celilo-mgr polls the registry and upgrades opted-in modules unattended. Spec: `openspec/specs/module-auto-upgrade/spec.md`. Entry points: `apps/celilo/src/cli/commands/module-upgrade.ts` — `runRegistryPoll` (the `--poll` path), `selectPollTargets` (pure: `autoUpgrade && latest && change ∉ {up-to-date, ahead}`), `upgradeOneModule` (update → backup → deploy → verify), `needsPreUpgradeBackup`, `pickAutoUpgrade`/`pickUpgradePolicy` (both fail closed/safe); `classifyVersionChange` in `module-update.ts` (treats a registry `+N` revision as a patch); `resolveDeployPosture` in `apps/celilo/src/services/deploy-posture.ts`. Trigger: celilo-mgmt's `registry-poll` subscription (`modules/celilo-mgmt/manifest.yml`) on `timer.tick.15m` with handler **`celilo module upgrade --poll`** — the flag is REQUIRED, since the dispatcher appends the event id positionally and a bare handler would consume it as the optional module name (silent: 3108 deliveries, 0 successes). Operator controls are framework config keys settable on ANY module (`FRAMEWORK_CONFIG_KEYS` in `module-config.ts`): `auto_upgrade` (opt-in, default false) and `upgrade_policy` (`by-semver`|`always-safe`|`always-fast`), validated at set time because both readers fail open. ⚠️ `always-safe` guarantees safe *posture*, NOT a backup — `needsPreUpgradeBackup` also requires the TARGET manifest to declare an `on_backup` hook, else it warns and proceeds. Confirm a data-bearing module declares `on_backup` before enabling `auto_upgrade` on it. The *build* half (app CI publishing a `.netapp` on merge) is not yet shipped — `openspec/changes/build-bus-poll-cd`.
|
|
352
353
|
- **MCP service (`@celilo/mcp`)** — `packages/mcp/src/` — an operator-facing stdio MCP server (official `@modelcontextprotocol/sdk`, bin `celilo-mcp`) that drives a remote celilo server over the Remote API for an AI client. Two-item config (`config.ts`: `server` + `defaultUser`, env or `~/.config/celilo-mcp/config.json`). Dual-principal auth (`auth.ts`: `celilo-mcp auth setup` enrolls read-only `celilo-mcp-ro` + full `celilo-mcp-rw` ed25519 keypairs, prints the exact `celilo api grant` lines the operator runs server-side). Transport (`transport.ts`): reuses `@celilo/core` `runRemoteClient`, selecting the principal by `ssh -i <key>` and capturing structured output. Tool surface is generated LIVE from the server's command registry — `registry-fetch.ts` fetches `celilo commands --json` (+ `service list --json` for configured providers) over the RO principal on connect; `tools-from-registry.ts` (pure) projects that into one tool per runnable leaf, grouped by top-level command (`celilo_module_*`, `celilo_proxmox_*`, …), each with a Zod input schema from the leaf's args/flags and a read/write tag → RO/RW routing, plus a generic `celilo_run` escape hatch. Auto-detect hides provider-gated groups (e.g. `celilo_proxmox_*` until a Proxmox service is configured) and re-detects on a timer, emitting `notifications/tools/list_changed` when the surface changes. Coverage gate (`tests/coverage.test.ts`) asserts every registry leaf maps to a tool. Composite RO troubleshooting tools (`troubleshoot.ts` pure correlation + `troubleshoot-tools.ts` thin adapters): `celilo_assess_module <id>` and `celilo_fleet_status` correlate `celilo audit --json` (the drift backbone) with the `module list --json` roster into a per-module / fleet-wide verdict. Design: `openspec/changes/celilo-mcp-service/proposal.md`. (Distinct from the dev/ops `@celilo/mcp-server` below.)
|
|
353
354
|
|
|
355
|
+
## Web console (read-mostly operator UI)
|
|
356
|
+
|
|
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 and the console read verbs exist; the module, its `control_plane_api` enrolment and the e2e suite do not.
|
|
358
|
+
|
|
359
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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-only principal. 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.
|
|
365
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
368
|
+
|
|
354
369
|
## E2E simulation
|
|
355
370
|
|
|
356
371
|
- **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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": "^3.
|
|
61
|
+
"@celilo/capabilities": "^3.3.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.
|
|
63
|
+
"@celilo/core": "^0.10.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getDb } from '../../db/client';
|
|
10
|
+
import { people } from '../../db/schema';
|
|
10
11
|
import { renderAlertTable, toAlertRow } from '../../services/alerting/format';
|
|
11
12
|
import { listMonitors } from '../../services/alerting/monitors';
|
|
12
13
|
import { policyForAlert } from '../../services/alerting/notify-deps';
|
|
@@ -23,10 +24,21 @@ export async function handleAlertsList(
|
|
|
23
24
|
|
|
24
25
|
if (flags.json) {
|
|
25
26
|
const monitorsById = new Map(listMonitors(db).map((m) => [m.id, m.target]));
|
|
27
|
+
const peopleById = new Map(
|
|
28
|
+
db
|
|
29
|
+
.select()
|
|
30
|
+
.from(people)
|
|
31
|
+
.all()
|
|
32
|
+
.map((p) => [p.id, p.name]),
|
|
33
|
+
);
|
|
26
34
|
return {
|
|
27
35
|
success: true,
|
|
28
36
|
message: JSON.stringify(
|
|
29
37
|
live.map((alert) => ({
|
|
38
|
+
// The row's own identifier, needed by anything that acts on ONE
|
|
39
|
+
// alert. `key` identifies the condition and is stable across
|
|
40
|
+
// restarts; `id` identifies this occurrence of it.
|
|
41
|
+
id: alert.id,
|
|
30
42
|
key: alert.key,
|
|
31
43
|
state: alert.state,
|
|
32
44
|
severity: alert.severity,
|
|
@@ -43,7 +55,10 @@ export async function handleAlertsList(
|
|
|
43
55
|
suppressedByAlertId: alert.suppressedByAlertId,
|
|
44
56
|
suppressedByWindowId: alert.suppressedByWindowId,
|
|
45
57
|
awaitingConfirmation: alert.awaitingConfirmation,
|
|
46
|
-
|
|
58
|
+
// Resolved to a name. The column is a `people` foreign key, and a
|
|
59
|
+
// bare id tells a reader nothing about who acknowledged their alert.
|
|
60
|
+
ackedBy: alert.ackedBy ? (peopleById.get(alert.ackedBy) ?? alert.ackedBy) : null,
|
|
61
|
+
ackedAt: alert.ackedAt,
|
|
47
62
|
silencedUntil: alert.silencedUntil,
|
|
48
63
|
message: alert.message,
|
|
49
64
|
})),
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from 'bun:test';
|
|
11
11
|
import type { Backup } from '../../db/schema';
|
|
12
|
-
import { backedUpModuleIds, lastSuccessSummaryLines } from './backup-list';
|
|
12
|
+
import { backedUpModuleIds, backupListJson, lastSuccessSummaryLines } from './backup-list';
|
|
13
13
|
|
|
14
14
|
const NOW = Date.UTC(2026, 7, 13, 12, 0, 0);
|
|
15
15
|
const ago = (ms: number) => new Date(NOW - ms);
|
|
@@ -81,3 +81,84 @@ describe('lastSuccessSummaryLines', () => {
|
|
|
81
81
|
expect(line).not.toContain('attempts');
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `--json`, which exists because the human output cannot be parsed back.
|
|
87
|
+
*
|
|
88
|
+
* `formatRelativeDate` buckets everything past six days — "last week", then
|
|
89
|
+
* "2 weeks ago", then "last month". A person reading a screen wants that. The
|
|
90
|
+
* web console draws one square per module per day and cannot place a row it
|
|
91
|
+
* cannot date, and a row it leaves out renders as a day on which no backup ran.
|
|
92
|
+
* Inventing a gap in somebody's backup coverage is the one thing that page must
|
|
93
|
+
* never do, so these pin the exactness rather than the formatting.
|
|
94
|
+
*/
|
|
95
|
+
describe('backupListJson', () => {
|
|
96
|
+
const AT = Date.UTC(2026, 6, 14, 18, 13, 6);
|
|
97
|
+
|
|
98
|
+
function row(partial: Partial<Backup>): Backup {
|
|
99
|
+
return backup({
|
|
100
|
+
id: 'da8522ea-4319-45fb-ae06-a6d3525f4b9f',
|
|
101
|
+
storageId: 'st-1',
|
|
102
|
+
storagePath: '2026-07-14/forgejo.backup',
|
|
103
|
+
status: 'completed',
|
|
104
|
+
sizeBytes: 2_900_000_000,
|
|
105
|
+
startedAt: new Date(AT),
|
|
106
|
+
completedAt: new Date(AT + 1000),
|
|
107
|
+
...partial,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const noDatabase = {
|
|
112
|
+
storageNameOf: () => 'aws-backups',
|
|
113
|
+
historyOf: () => ({
|
|
114
|
+
lastSuccessAt: new Date(AT),
|
|
115
|
+
lastAttemptAt: new Date(AT),
|
|
116
|
+
consecutiveFailures: 0,
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function parse(backups: Backup[], windowDays: number | null = null) {
|
|
121
|
+
const result = backupListJson(backups, windowDays, noDatabase);
|
|
122
|
+
// Narrowed rather than asserted: `message` lives only on the success arm,
|
|
123
|
+
// and a failure here should say what went wrong, not read as empty JSON.
|
|
124
|
+
if (!result.success) throw new Error(result.error);
|
|
125
|
+
return JSON.parse(result.message ?? '{}');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('a timestamp survives as an exact instant, not a bucket', () => {
|
|
129
|
+
// The same row the human path renders as "last month".
|
|
130
|
+
const [only] = parse([row({})].map((b) => b)).backups;
|
|
131
|
+
expect(only.startedAt).toBe(AT);
|
|
132
|
+
expect(only.completedAt).toBe(AT + 1000);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('the short id is offered so a caller need not slice a UUID', () => {
|
|
136
|
+
expect(parse([row({})]).backups[0].shortId).toBe('da8522ea');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a failed attempt reports no size and no completion', () => {
|
|
140
|
+
// Not zero and not "now". A failure kept nothing and never finished, and
|
|
141
|
+
// both of those are facts a caller renders differently from a small backup.
|
|
142
|
+
const [only] = parse([row({ status: 'failed', sizeBytes: null, completedAt: null })]).backups;
|
|
143
|
+
expect(only.sizeBytes).toBeNull();
|
|
144
|
+
expect(only.completedAt).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the failure cause is carried, because one red square is not one problem', () => {
|
|
148
|
+
const [only] = parse([row({ status: 'failed', errorMessage: 'ENOSPC' })]).backups;
|
|
149
|
+
expect(only.error).toBe('ENOSPC');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('windowDays is echoed, so a caller can tell "nothing ran" from "not asked"', () => {
|
|
153
|
+
// Null means unbounded. A number means the rows are complete only that far
|
|
154
|
+
// back, and an empty day beyond it asserts nothing.
|
|
155
|
+
expect(parse([row({})], 30).windowDays).toBe(30);
|
|
156
|
+
expect(parse([row({})], null).windowDays).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('an empty listing is a valid answer, not an error', () => {
|
|
160
|
+
const empty = parse([]);
|
|
161
|
+
expect(empty.backups).toEqual([]);
|
|
162
|
+
expect(empty.modules).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
listBackups,
|
|
11
11
|
loadBackupHistory,
|
|
12
12
|
} from '../../services/backup-metadata';
|
|
13
|
+
import type { BackupHistory } from '../../services/backup-schedule';
|
|
13
14
|
import { getBackupStorage } from '../../services/backup-storage';
|
|
14
15
|
import { celiloIntro } from '../prompts';
|
|
15
16
|
import type { CommandResult } from '../types';
|
|
@@ -136,28 +137,136 @@ export function lastSuccessSummaryLines(
|
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The listing as data, for anything that is not a person reading a terminal.
|
|
142
|
+
*
|
|
143
|
+
* This exists because the human output cannot be parsed back into facts. Ages
|
|
144
|
+
* are rendered relative and BUCKETED — everything past six days collapses to
|
|
145
|
+
* "last week", then "2 weeks ago", then "last month" — so a caller reading the
|
|
146
|
+
* text can place a backup on a day for six days and not one day further.
|
|
147
|
+
*
|
|
148
|
+
* That is fine for a person, who is asking "is this recent". It is not fine for
|
|
149
|
+
* the web console, which draws one square per module per day: a row it cannot
|
|
150
|
+
* place is a row it must leave out, and a missing row renders as a day on which
|
|
151
|
+
* no backup ran. The one thing a coverage grid must never do is invent a gap.
|
|
152
|
+
*
|
|
153
|
+
* So the timestamps go out as epoch ms, exactly as stored, and the caller
|
|
154
|
+
* decides how to say them. Everything else here is what the human path already
|
|
155
|
+
* shows — the same rows and the same per-module summary — because a second
|
|
156
|
+
* source of truth for "what backups exist" is worth less than no second source.
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* The two database reads this needs, injectable so the shape can be tested
|
|
160
|
+
* without one (Rule 2.3). The defaults are the real thing.
|
|
161
|
+
*/
|
|
162
|
+
export interface BackupListJsonDeps {
|
|
163
|
+
storageNameOf: (storageId: string) => string;
|
|
164
|
+
historyOf: (moduleId: string) => BackupHistory;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const LIVE_LOOKUPS: BackupListJsonDeps = {
|
|
168
|
+
storageNameOf: (storageId) => getBackupStorage(storageId)?.storageId ?? 'unknown',
|
|
169
|
+
historyOf: loadBackupHistory,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export function backupListJson(
|
|
173
|
+
backupList: Backup[],
|
|
174
|
+
windowDays: number | null,
|
|
175
|
+
deps: BackupListJsonDeps = LIVE_LOOKUPS,
|
|
176
|
+
): CommandResult {
|
|
177
|
+
// One lookup per distinct destination rather than one per row: a month of a
|
|
178
|
+
// failing module is hundreds of rows and two or three storages.
|
|
179
|
+
const storageNames = new Map<string, string>();
|
|
180
|
+
for (const backup of backupList) {
|
|
181
|
+
if (storageNames.has(backup.storageId)) continue;
|
|
182
|
+
storageNames.set(backup.storageId, deps.storageNameOf(backup.storageId));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const payload = {
|
|
186
|
+
asOf: Date.now(),
|
|
187
|
+
/**
|
|
188
|
+
* How far back the rows are COMPLETE for, or null when unbounded.
|
|
189
|
+
*
|
|
190
|
+
* The caller needs this to tell "nothing ran that day" from "you did not
|
|
191
|
+
* ask about that day". They look identical in the data and only one of them
|
|
192
|
+
* is somebody's missing backup.
|
|
193
|
+
*/
|
|
194
|
+
windowDays,
|
|
195
|
+
backups: backupList.map((backup) => ({
|
|
196
|
+
id: backup.id,
|
|
197
|
+
shortId: backup.id.substring(0, 8),
|
|
198
|
+
moduleId: backup.moduleId,
|
|
199
|
+
backupType: backup.backupType,
|
|
200
|
+
status: backup.status,
|
|
201
|
+
sizeBytes: backup.sizeBytes,
|
|
202
|
+
// Epoch ms, both of them. `startedAt` is when the attempt began and
|
|
203
|
+
// `completedAt` is when it finished; a failed attempt has no second one.
|
|
204
|
+
startedAt: backup.startedAt.getTime(),
|
|
205
|
+
completedAt: backup.completedAt ? backup.completedAt.getTime() : null,
|
|
206
|
+
storage: storageNames.get(backup.storageId) ?? 'unknown',
|
|
207
|
+
storagePath: backup.storagePath,
|
|
208
|
+
moduleVersion: backup.moduleVersion,
|
|
209
|
+
schemaVersion: backup.schemaVersion,
|
|
210
|
+
name: backup.name,
|
|
211
|
+
error: backup.errorMessage,
|
|
212
|
+
})),
|
|
213
|
+
modules: backedUpModuleIds(backupList).map((moduleId) => {
|
|
214
|
+
const history = deps.historyOf(moduleId);
|
|
215
|
+
return {
|
|
216
|
+
moduleId,
|
|
217
|
+
lastSuccessAt: history.lastSuccessAt ? history.lastSuccessAt.getTime() : null,
|
|
218
|
+
lastAttemptAt: history.lastAttemptAt ? history.lastAttemptAt.getTime() : null,
|
|
219
|
+
consecutiveFailures: history.consecutiveFailures,
|
|
220
|
+
};
|
|
221
|
+
}),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// `rawOutput` keeps the payload out of the decorating renderer, which would
|
|
225
|
+
// wrap it and stop it parsing (celilo#698).
|
|
226
|
+
return { success: true, message: JSON.stringify(payload, null, 2), rawOutput: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
139
229
|
export async function handleBackupList(
|
|
140
230
|
args: string[],
|
|
141
231
|
flags: Record<string, boolean | string> = {},
|
|
142
232
|
): Promise<CommandResult> {
|
|
143
233
|
try {
|
|
144
234
|
const moduleIdOrBackupId = args[0];
|
|
145
|
-
|
|
235
|
+
// A JSON caller is a program with a window in mind, not a person skimming a
|
|
236
|
+
// screen, so 20 is the wrong ceiling for it. It still takes --limit.
|
|
237
|
+
const defaultLimit = flags.json ? 5000 : 20;
|
|
238
|
+
const limit = typeof flags.limit === 'string' ? Number.parseInt(flags.limit, 10) : defaultLimit;
|
|
239
|
+
const windowDays = typeof flags.since === 'string' ? Number.parseInt(flags.since, 10) : null;
|
|
240
|
+
if (windowDays !== null && (!Number.isFinite(windowDays) || windowDays <= 0)) {
|
|
241
|
+
return {
|
|
242
|
+
success: false,
|
|
243
|
+
error: `--since takes a positive number of days, got: ${flags.since}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const since = windowDays === null ? undefined : Date.now() - windowDays * 86_400_000;
|
|
146
247
|
|
|
147
248
|
// If the argument looks like a backup ID (hex chars), show detail view
|
|
148
249
|
if (moduleIdOrBackupId && /^[0-9a-f]{8,}$/i.test(moduleIdOrBackupId)) {
|
|
149
250
|
const backup = getBackup(moduleIdOrBackupId);
|
|
150
251
|
if (backup) {
|
|
252
|
+
// The same envelope as the listing, holding one row. A caller that can
|
|
253
|
+
// parse `backup list --json` can parse this without a second shape, and
|
|
254
|
+
// asking about one backup is the commonest scripted case there is.
|
|
255
|
+
if (flags.json) return backupListJson([backup], null);
|
|
151
256
|
celiloIntro('Backup Detail');
|
|
152
257
|
return showBackupDetail(moduleIdOrBackupId);
|
|
153
258
|
}
|
|
154
259
|
// Fall through to module filter if not a backup ID
|
|
155
260
|
}
|
|
156
261
|
|
|
157
|
-
celiloIntro('Available Backups');
|
|
158
|
-
|
|
159
262
|
const moduleId = moduleIdOrBackupId;
|
|
160
|
-
const backupList = listBackups({ moduleId, limit });
|
|
263
|
+
const backupList = listBackups({ moduleId, limit, since });
|
|
264
|
+
|
|
265
|
+
// Before the banner: --json must emit JSON and nothing else, or the first
|
|
266
|
+
// thing a parser meets is a celilo logo.
|
|
267
|
+
if (flags.json) return backupListJson(backupList, windowDays);
|
|
268
|
+
|
|
269
|
+
celiloIntro('Available Backups');
|
|
161
270
|
|
|
162
271
|
if (backupList.length === 0) {
|
|
163
272
|
console.log('No backups found.\n');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo console …` — the narrow reads the web console polls.
|
|
3
|
+
*
|
|
4
|
+
* A NOTE ON THE VERB NAMES, because they read oddly on purpose. celilo's remote
|
|
5
|
+
* API authorises at two levels (`command:subcommand`), and a leaf is classified
|
|
6
|
+
* read-only by its subcommand token against `READ_VERBS` in `@celilo/core`. So
|
|
7
|
+
* these are named from that vocabulary — `status`, `list`, `get` — rather than
|
|
8
|
+
* for prose. `console roster` would be a nicer name and would classify as a
|
|
9
|
+
* WRITE, which would either deny the console its own data or require widening
|
|
10
|
+
* the read-verb set for every principal in the fleet.
|
|
11
|
+
*
|
|
12
|
+
* All three are reads. The console's principal is granted read ops only, so a
|
|
13
|
+
* mutating verb added to this file would be refused at the API boundary rather
|
|
14
|
+
* than running.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_CLOSURE_DEPTH,
|
|
19
|
+
UNBOUNDED_CLOSURE_DEPTH,
|
|
20
|
+
computeClosure,
|
|
21
|
+
} from '../../console/closure';
|
|
22
|
+
import {
|
|
23
|
+
consoleStatus,
|
|
24
|
+
loadBindings,
|
|
25
|
+
loadClosureInputs,
|
|
26
|
+
moduleExists,
|
|
27
|
+
} from '../../console/projection';
|
|
28
|
+
import { getDb } from '../../db/client';
|
|
29
|
+
import { loadCapabilityProviderRows } from '../../services/consumer-cleanup';
|
|
30
|
+
import { hasFlag } from '../parser';
|
|
31
|
+
import type { CommandResult } from '../types';
|
|
32
|
+
|
|
33
|
+
/** `celilo console status [--json]` — the dashboard's single poll. */
|
|
34
|
+
export function handleConsoleStatus(flags: Record<string, string | boolean> = {}): CommandResult {
|
|
35
|
+
const payload = consoleStatus(getDb());
|
|
36
|
+
|
|
37
|
+
if (hasFlag(flags, 'json')) {
|
|
38
|
+
return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lines = payload.modules.map(
|
|
42
|
+
(m) =>
|
|
43
|
+
`${m.id.padEnd(24)} ${m.state.padEnd(12)} ${m.health.cell.padEnd(14)} ${
|
|
44
|
+
m.systems.map((s) => `${s.hostname}@${s.zone}`).join(', ') || '(no system)'
|
|
45
|
+
}`,
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
message: [`zones: ${payload.zones.join(' > ')}`, '', ...lines].join('\n'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `celilo console get <module-id> [--depth N] [--json]` — one module's bounded
|
|
55
|
+
* capability closure.
|
|
56
|
+
*
|
|
57
|
+
* `--depth 0` walks the whole graph. The walk terminates on cycles either way.
|
|
58
|
+
*/
|
|
59
|
+
export function handleConsoleGet(
|
|
60
|
+
args: string[],
|
|
61
|
+
flags: Record<string, string | boolean> = {},
|
|
62
|
+
): CommandResult {
|
|
63
|
+
const moduleId = args[0];
|
|
64
|
+
if (!moduleId) {
|
|
65
|
+
return { success: false, error: 'Module ID required\n\nUsage: celilo console get <module-id>' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const db = getDb();
|
|
69
|
+
if (!moduleExists(db, moduleId)) {
|
|
70
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const depth = parseDepth(flags.depth);
|
|
74
|
+
if (depth instanceof Error) return { success: false, error: depth.message };
|
|
75
|
+
|
|
76
|
+
const { manifests, providerStates } = loadClosureInputs(db);
|
|
77
|
+
|
|
78
|
+
// Real bindings, for every module the walk might reach. The console draws what
|
|
79
|
+
// the fleet IS doing, not what its manifests permit.
|
|
80
|
+
const bindings = new Map([...manifests.keys()].map((id) => [id, loadBindings(db, id)] as const));
|
|
81
|
+
|
|
82
|
+
const result = computeClosure({
|
|
83
|
+
rootModuleId: moduleId,
|
|
84
|
+
manifests,
|
|
85
|
+
providerRows: loadCapabilityProviderRows(db),
|
|
86
|
+
providerStates,
|
|
87
|
+
depth,
|
|
88
|
+
bindings,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (hasFlag(flags, 'json')) {
|
|
92
|
+
return { success: true, message: JSON.stringify(result), rawOutput: true, data: result };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (result.nodes.length === 0) {
|
|
96
|
+
// An answer, not a failure. The console says the same thing in words rather
|
|
97
|
+
// than rendering an empty picture that reads as still loading.
|
|
98
|
+
return { success: true, message: `${moduleId} depends on nothing.` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
message: result.nodes
|
|
104
|
+
.map(
|
|
105
|
+
(n) =>
|
|
106
|
+
`hop ${n.hop} ${n.moduleId.padEnd(24)} ${n.optional ? '(optional)' : ' '} via ${n.via.join(', ')}`,
|
|
107
|
+
)
|
|
108
|
+
.join('\n'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Depth flag, or an Error explaining what was wrong with it. */
|
|
113
|
+
function parseDepth(raw: string | boolean | undefined): number | Error {
|
|
114
|
+
if (raw === undefined || raw === true) return DEFAULT_CLOSURE_DEPTH;
|
|
115
|
+
const parsed = Number(raw);
|
|
116
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
117
|
+
return new Error(
|
|
118
|
+
`Invalid --depth: ${raw}\n\nExpected a non-negative integer (${UNBOUNDED_CLOSURE_DEPTH} walks the whole graph).`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { moduleHealthCell } from '../../services/alerting/format';
|
|
5
|
-
import { loadAllLiveAlerts, summariseByModule } from '../../services/alerting/store';
|
|
1
|
+
import { getDb } from '../../db/client';
|
|
2
|
+
import { modules } from '../../db/schema';
|
|
3
|
+
import { loadObservedHealth } from '../../services/alerting/observed-health';
|
|
6
4
|
import { formatPausedDuration } from '../../services/module-pause';
|
|
7
5
|
import { hasFlag } from '../parser';
|
|
8
6
|
import type { CommandResult } from '../types';
|
|
@@ -77,39 +75,3 @@ export async function handleModuleList(
|
|
|
77
75
|
data: moduleRows,
|
|
78
76
|
};
|
|
79
77
|
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Observed health per module: `ok`, `N firing`, `suppressed`, or
|
|
83
|
-
* `not observed`.
|
|
84
|
-
*
|
|
85
|
-
* Only computed for DEPLOYED modules — a module still being imported has
|
|
86
|
-
* nothing to observe, and reporting it as unwatched would be noise rather
|
|
87
|
-
* than a finding.
|
|
88
|
-
*/
|
|
89
|
-
function loadObservedHealth(db: DbClient): Map<string, string> {
|
|
90
|
-
const monitored = new Set(
|
|
91
|
-
db
|
|
92
|
-
.select({ target: monitors.target })
|
|
93
|
-
.from(monitors)
|
|
94
|
-
.where(eq(monitors.enabled, true))
|
|
95
|
-
.all()
|
|
96
|
-
.map((m) => m.target),
|
|
97
|
-
);
|
|
98
|
-
const byModule = summariseByModule(loadAllLiveAlerts(db));
|
|
99
|
-
|
|
100
|
-
const result = new Map<string, string>();
|
|
101
|
-
for (const module of db.select().from(modulesTable).all()) {
|
|
102
|
-
if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
|
|
103
|
-
|
|
104
|
-
const summary = byModule.get(module.id) ?? [];
|
|
105
|
-
result.set(
|
|
106
|
-
module.id,
|
|
107
|
-
moduleHealthCell({
|
|
108
|
-
monitored: monitored.has(module.id),
|
|
109
|
-
firingCount: summary.filter((s) => !s.suppressed).length,
|
|
110
|
-
suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
return result;
|
|
115
|
-
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -37,6 +37,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
37
37
|
'backup',
|
|
38
38
|
'capability',
|
|
39
39
|
'commands',
|
|
40
|
+
'console',
|
|
40
41
|
'dns',
|
|
41
42
|
'completion',
|
|
42
43
|
'alerts',
|
|
@@ -658,6 +659,10 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
658
659
|
}
|
|
659
660
|
|
|
660
661
|
// Completion subcommands
|
|
662
|
+
if (command === 'console' && currentIndex === 1) {
|
|
663
|
+
return filterSuggestions(['status', 'get'], args[1] || '');
|
|
664
|
+
}
|
|
665
|
+
|
|
661
666
|
if (command === 'completion' && currentIndex === 1) {
|
|
662
667
|
const subcommands = ['bash', 'zsh'];
|
|
663
668
|
return filterSuggestions(subcommands, args[1] || '');
|
package/src/cli/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { handleCapabilityInfo } from './commands/capability-info';
|
|
|
25
25
|
import { handleCapabilityList } from './commands/capability-list';
|
|
26
26
|
import { handleCommands } from './commands/commands-json';
|
|
27
27
|
import { handleCompletion } from './commands/completion';
|
|
28
|
+
import { handleConsoleGet, handleConsoleStatus } from './commands/console';
|
|
28
29
|
import { handleDnsRegistrations } from './commands/dns';
|
|
29
30
|
import {
|
|
30
31
|
handleEventsAck,
|
|
@@ -225,6 +226,7 @@ Commands:
|
|
|
225
226
|
api Manage remote-API access (principals, grants, authorized_keys)
|
|
226
227
|
completion Generate shell completion scripts (bash/zsh)
|
|
227
228
|
commands Print the CLI command registry as JSON (drives @celilo/mcp)
|
|
229
|
+
console Narrow read-only projections for the web console
|
|
228
230
|
|
|
229
231
|
help, --help, -h Show this help message
|
|
230
232
|
|
|
@@ -803,7 +805,11 @@ Subcommands:
|
|
|
803
805
|
|
|
804
806
|
list [module-id] List available backups
|
|
805
807
|
Options:
|
|
806
|
-
--limit <n> Number of backups to show (default: 20)
|
|
808
|
+
--limit <n> Number of backups to show (default: 20; 5000 with --json)
|
|
809
|
+
--json Emit the listing as JSON with EXACT timestamps.
|
|
810
|
+
The human output buckets ages past six days into
|
|
811
|
+
"last week", so it cannot be parsed back into days.
|
|
812
|
+
--since <days> Only attempts from the last N days
|
|
807
813
|
|
|
808
814
|
restore <backup-id> Restore from a backup
|
|
809
815
|
Options:
|
|
@@ -1275,6 +1281,24 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1275
1281
|
return handleCommands(parsed.args, parsed.flags);
|
|
1276
1282
|
}
|
|
1277
1283
|
|
|
1284
|
+
// The web console's narrow reads.
|
|
1285
|
+
if (parsed.command === 'console') {
|
|
1286
|
+
const flagError = checkFlags('console', parsed.subcommand, parsed.flags, parsed.args);
|
|
1287
|
+
if (flagError) return flagError;
|
|
1288
|
+
|
|
1289
|
+
switch (parsed.subcommand) {
|
|
1290
|
+
case 'status':
|
|
1291
|
+
return handleConsoleStatus(parsed.flags);
|
|
1292
|
+
case 'get':
|
|
1293
|
+
return handleConsoleGet(parsed.args, parsed.flags);
|
|
1294
|
+
default:
|
|
1295
|
+
return {
|
|
1296
|
+
success: false,
|
|
1297
|
+
error: 'Console subcommand required: status | get',
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1278
1302
|
// Top-level alias: `celilo audit` → `celilo system audit`
|
|
1279
1303
|
if (parsed.command === 'audit') {
|
|
1280
1304
|
return handleSystemAudit(parsed.args, parsed.flags);
|