@celilo/cli 0.21.0 → 0.23.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 +5 -4
- package/CELILO_SUBSYSTEMS.md +34 -2
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -6
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/events.test.ts +66 -0
- package/src/cli/commands/events.ts +106 -3
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +3 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/completion.ts +5 -0
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +49 -20
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/services/alerting/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/alerting/sweep-runner.ts +11 -1
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/dns-registrations.ts +12 -0
- package/src/services/fleet-checks.test.ts +46 -0
- package/src/services/fleet-checks.ts +63 -6
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.ts +2 -39
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- package/src/test-utils/setup-test-db.ts +13 -23
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -14,19 +14,20 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
14
14
|
> the `@celilo/cli` npm package). Re-grep manifests if an entry looks stale.
|
|
15
15
|
|
|
16
16
|
> How the graph closes: a public app (`requires: public_web`) is served by **caddy**, which
|
|
17
|
-
> `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**
|
|
18
|
-
> **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
|
|
17
|
+
> `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**axon** /
|
|
18
|
+
> **greenwave** / **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
|
|
19
19
|
> (**knot-unbound-internal** / **technitium**). Identity comes from **authentik** (`idp`). That's
|
|
20
20
|
> the whole edge: DNS + firewall + ingress + identity, each a swappable provider module.
|
|
21
21
|
|
|
22
22
|
## Network fabric (DNS / firewall / DHCP)
|
|
23
23
|
|
|
24
|
-
- **
|
|
24
|
+
- **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**.
|
|
25
|
+
- **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
|
|
25
26
|
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
|
|
26
27
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
27
28
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
28
29
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
|
|
29
|
-
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does
|
|
30
|
+
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
30
31
|
|
|
31
32
|
## Public edge (ingress / identity)
|
|
32
33
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -170,8 +170,26 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
170
170
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
171
171
|
- **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).
|
|
172
172
|
- **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
|
|
173
|
+
- **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/changes/module-pause-lifecycle/`.
|
|
174
|
+
- **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
|
|
173
175
|
- **Backup artifact encryption** — `apps/celilo/src/services/backup-cipher.ts` — `encryptFileToFile`/`decryptFileToFile`, file-in/file-out and streamed, used by every backup writer (`backup-create.ts`) and reader (`backup-restore.ts`, `restore-from-file.ts`). Do NOT route artifacts through `secrets/encryption.ts`: that API is string-in/string-out for short DB values, and feeding it a tar cost base64 (1.33x) then hex (2x) then `JSON.stringify` — ~9x the artifact in memory, which OOM-killed forgejo's 774 MB backup, and a hard ~805 MB ceiling from the max string length that no amount of RAM raises. On-disk format is `magic "CELILOBK" (8) | version (1) | iv (16) | ciphertext | GCM tag (16)`; the tag is a trailer because it does not exist until the last byte is encrypted. `decryptFileToFile` still reads the pre-2026-07 JSON-envelope artifacts, discriminating on the magic bytes — the envelope's own `schemaVersion` cannot serve, as it lives inside the encrypted tar.
|
|
174
176
|
|
|
177
|
+
### Paused modules are conspicuous
|
|
178
|
+
|
|
179
|
+
A pause switches OFF the alerting that would otherwise report the module as
|
|
180
|
+
down, so the paused-ness itself is the signal. Four surfaces, none optional:
|
|
181
|
+
`celilo module list` / `celilo status` render `PAUSED (3d)` with the reason;
|
|
182
|
+
`checkPausedModules` in `apps/celilo/src/services/fleet-checks.ts` makes ANY
|
|
183
|
+
paused module a `system doctor` FAILURE (no threshold — a pause is a degraded
|
|
184
|
+
state, and a threshold is just something to tune until the detector stops
|
|
185
|
+
firing); `findSuppressor` in `apps/celilo/src/services/alerting/suppression.ts`
|
|
186
|
+
attributes the module's suppressed alerts to the pause rather than dropping them
|
|
187
|
+
anonymously; and `fleetWarnings()` in `apps/celilo/src/api/serve.ts` stamps a
|
|
188
|
+
warning naming every paused module and its age onto EVERY management-API result
|
|
189
|
+
(`ResultMessage.warnings`), including on commands unrelated to those modules.
|
|
190
|
+
That last one is the important half: it does not depend on the operator choosing
|
|
191
|
+
to look, which is how a forgotten pause actually gets found.
|
|
192
|
+
|
|
175
193
|
## Generation & templating
|
|
176
194
|
|
|
177
195
|
- **Generator** — `apps/celilo/src/templates/generator.ts` — `generateTemplates` (orchestration), plus Terraform/Ansible file handling.
|
|
@@ -238,6 +256,20 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
|
|
|
238
256
|
- **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`). **Exactly one dispatcher per bus**: `runDispatcher` refuses to start while another dispatcher's process is alive (`assertSoleDispatcher` in `dispatcher.ts`, liveness via `bus.liveDispatchers()` — `kill(pid,0)`, not heartbeat age, since a tick blocks for as long as its slowest handler). The exclusion is here rather than in the systemd unit because a stranded dispatcher can sit outside the unit's cgroup where `KillMode` cannot reach it (#580). `bus.health()` reports `dispatcherCount`/`dispatchers` and a `duplicate_dispatcher` status; `checkDispatcher` (`services/fleet-checks.ts`) fails on more than one. **Supervision** (`services/events-daemon.ts`): the unit is named `celilo-events.service` in BOTH the user and system scope, so the two are indistinguishable in every operator-facing string — `install-daemon` (which defaults to **user** scope) therefore refuses when the other scope's unit exists, and `checkDispatcher` compares the live pid against each installed unit's `MainPID` (`unitMainPid`) rather than merely testing that a unit file exists, since a file nobody is running is not supervision (#610).
|
|
239
257
|
- **Dispatcher supervision (install / restart)** — `apps/celilo/src/services/events-daemon.ts` — `installDaemon`/`planDaemonInstall`/`uninstallDaemon`/`readInstalledUnit` write the systemd unit or launchd plist without touching supervisor state, and `restartDaemon` (+ pure `orphanDispatcherPids`, `resolveRestartScope`, `supervisorCommands`) is the one verb that DOES cycle it. CLI: `celilo events install-daemon|uninstall-daemon|show-daemon|restart-daemon [--system]`. `restart-daemon` exists because the dispatcher runs the code it LOADED: celilo-mgr sat 9 days on event-bus v0.1.8 after apt installed v0.2.0, running the very bug the release fixed (celilo#604). Two things make it non-trivial and both are load-bearing: (1) it stops any live dispatcher the supervisor does not own first — an ORPHAN (PPID 1) is invisible to `systemctl restart`, and `assertSoleDispatcher` then crash-loops the unit while the old code keeps serving; (2) it verifies on the BUS that a NEW pid is live reporting `BUS_VERSION`, never off systemctl's exit code, which returns 0 into exactly that crash loop. System scope shells `sudo systemctl` (the unit is root-owned; celilo runs unprivileged), covered by the scoped `/etc/sudoers.d/celilo-events-restart` conffile that `celilo-bootstrap` ships — a unit test asserts the argv and the grant cannot drift apart.
|
|
240
258
|
|
|
259
|
+
## Terminal UI (`@celilo/cli-display`)
|
|
260
|
+
|
|
261
|
+
Every CLI-facing primitive celilo renders with. Lives in `packages/cli-display/src/` so module code running in-process (capability functions, hook scripts) can reach it via `@celilo/capabilities`' re-export, and so `@celilo/core` can use it without depending on `@celilo/cli`. `@clack/prompts` was removed tree-wide in celilo#699; nothing here wraps a third-party prompt library.
|
|
262
|
+
|
|
263
|
+
**The stream contract:** stdout carries a command's RESULT and nothing else — no glyph, no box-drawing prefix, no ANSI. Everything else (prompts, progress, banners, log lines, diagnostics) goes to **stderr**. This is what lets `celilo module list | grep '^caddy '` and `celilo events tail | jq` work without a repair step; the previous renderer put chrome on stdout, which cost a full e2e run when a `│ ` prefix read as a missing module (celilo#695) and made ten JSON commands unparseable (celilo#698).
|
|
264
|
+
|
|
265
|
+
- **Prompts** — `packages/cli-display/src/prompt.ts` — `text`, `password`, `confirm`, `select`, `multiselect`, plus `CANCEL`/`isCancel` and `isInteractive`. Raw-mode keypress handling with cursor movement, submit-time validation, and per-keystroke tokenization (one stdin chunk can carry a whole paste). **Refuses a non-TTY stdin** with `PromptUnavailableError` instead of hanging on it or taking the next newline as a considered answer — both real past failures. New interactive decisions must NOT be added here; they belong on the event-bus interview, which a headless responder can answer.
|
|
266
|
+
- **Message chrome** — `packages/cli-display/src/messages.ts` — `intro`, `outro`, `note`, `cancel`, and `log.{success,error,warn,info,message,step}`. All to stderr; routed into the active `ProgressDisplay` when one is set, so the two renderers never fight over the cursor.
|
|
267
|
+
- **Colour** — `packages/cli-display/src/colors.ts` — `colorEnabled()`, `paint()`, and the `colors` token object. Gated per use on `NO_COLOR` → `FORCE_COLOR` → `stderr.isTTY`, so colour never reaches a pipe, a file, or protocol mode.
|
|
268
|
+
- **Progress display** — `packages/cli-display/src/progress-display.ts` — `ProgressDisplay`: a flat append-only stream with a time gutter and one status-glyph vocabulary in `render` mode, or structured `[progress:start|done|fail|sub]` markers in `protocol` mode (what the Remote API server emits and the client re-renders locally).
|
|
269
|
+
- **Active-display singleton** — `packages/cli-display/src/active-display.ts` — `getActiveDisplay`/`setActiveDisplay`; how a long-running command lets everything down its call chain route output through one display.
|
|
270
|
+
- **celilo-side wrapper** — `apps/celilo/src/cli/prompts.ts` — `celiloIntro`/`celiloOutro`, `promptText`/`promptPassword`/`promptConfirm`, `showNote`, `log`. Roughly 160 `log.*` call sites route through this one module, which is why swapping the underlying implementation was a one-file change.
|
|
271
|
+
- **FuelGauge** — `apps/celilo/src/cli/fuel-gauge.ts` — the Cylon-style indicator for operations over ~3s (ESC to background, ^C to cancel); delegates to the active `ProgressDisplay` when one is set.
|
|
272
|
+
|
|
241
273
|
## Remote API (drive the CLI over the wire)
|
|
242
274
|
|
|
243
275
|
Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <host> celilo …`. Typed, streamed, per-operation authz, mid-run interviews. Design: `openspec/changes/replace-ssh-cli-api/proposal.md`.
|
|
@@ -246,7 +278,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
246
278
|
- **Wire protocol** — `packages/core/src/protocol.ts` (`@celilo/core`) — versioned NDJSON tagged union (`command`/`progress`/`log`/`result`/`error`/`interview`/`answer`) + `translateOutputLine`.
|
|
247
279
|
- **Registry serialization** — `apps/celilo/src/cli/commands/commands-json.ts` — `celilo commands --json` prints the full `COMMANDS` tree as JSON; the live source of truth the MCP fetches to generate its tool surface (so it mirrors whatever celilo version the server runs). `service list --json` similarly exposes configured providers for MCP auto-detect. `module list --json` prints the module roster (id/version/state) as stable JSON — the backbone the MCP composite troubleshooting tools correlate `audit --json` findings against.
|
|
248
280
|
- **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.
|
|
249
|
-
- **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
|
|
281
|
+
- **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.
|
|
250
282
|
- **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`).
|
|
251
283
|
- **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
|
|
252
284
|
- **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.
|
|
@@ -259,6 +291,6 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
259
291
|
- **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
|
|
260
292
|
- **Run wrapper** — `infra/scripts/cele2e-run.sh` lives in the separate `infra/` clone, **not** in this repo. See the cele2e section of the repo-root `CLAUDE.md` for the operator workflow.
|
|
261
293
|
- **Signal simulators** — three containers, three jobs. `docker/Dockerfile.signal-cli` runs the REAL unlinked daemon (`network().withSignalCli()`, reachable at `signal-cli.lab`) so `e2e/tests/signal-contract.test.ts` can re-check celilo's understanding of the JSON-RPC surface against the actual binary — the pebble pattern. `docker/Dockerfile.signal-sim` runs `simulators/signal-cli/server.ts` (`withSignalSim()`, `signal-sim.lab`), the drivable stand-in with a control surface (`/_control/inbound`, `/_control/sent`, `/_control/unlink`). `/_control/inbound` from the LINKED account's own number emits a `syncMessage.sentMessage` transcript rather than a `dataMessage`, because that is the only shape the real daemon delivers a note-to-self in — and a note-to-self is what every reply is in the default single-operator setup (#460). `docker/Dockerfile.signal-release` (`withSignalRelease()`, `signal-release.lab`) serves the signal-cli release tarball so the module's deploy-time download resolves inside the sealed network — the download is served, never skipped. The libsignal aarch64 native is compiled at `build-infra` time from the module's own recipe (`packages/e2e/scripts/stage-libsignal.ts` → `modules/signal/build/`) and staged into the apt-repo pool, so the recipe is exercised for real on every rebuild while the deploy stays fast and the network stays sealed.
|
|
262
|
-
- **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `
|
|
294
|
+
- **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `203.0.113.100`. The ISP edge `fw-ext` ROUTES the customer's `203.0.113.0/24` and must never re-NAT it (`-s 203.0.113.0/24 -j RETURN` ahead of its MASQUERADE) — an ISP does not NAT a subscriber that already holds a public address. This is load-bearing, not cosmetic: Namecheap-style DDNS registers the SOURCE address when the caller omits `ip=`, which is how celilo registers public names since #464/#466, so a second NAT here publishes the simulator's own address for every public hostname and quietly breaks ACME, inbound reach, and seeded apex records. Constants: `externalWanIp()` / `externalWanSubnet()` in `src/types.ts`. The corollary: every simulator on `internet-external` must default-route via fw-ext (`100.64.0.1`) so it can reply to the customer's public address — Docker's bridge gateway has no path across networks. `config/routing/public-sim-entrypoint.sh` is the shared two-liner; the DNS hierarchy, pebble, isitup and celilo-website-sim already carried it, and npm-registry / registry / apt-repo / minio / cpanel-host only appeared to work because the double NAT put fw-ext's on-link address in the source field.
|
|
263
295
|
- **Simulated address plan** — `packages/e2e/src/types.ts` — `SIM_PRIVATE_SUPERNET` (`10.226.0.0/16`), `zoneIp(zone, host)`, `ZONE_SUBNETS`, `ZONE_GATEWAYS`. Every simulated PRIVATE zone is derived from this one table; the compose generator, `zone-classifier.ts` and the harness's `system init` all read it, so renumbering the whole sim is a one-line change. The sim deliberately does NOT reuse a real fleet's zone /24s (#539): the previous plan was byte-identical to production's, so a stack leaked on celilo's own forgejo-builder — which lives in the real dmz — claimed the builder's own subnet and blackholed every containerized CI job's route to the forge for ~135s. Teardown cannot prevent that (a SIGKILL runs no handler), so the addresses moved instead; the second, better reason is that a suite which only passes on production's exact octets is asserting one site's address plan rather than celilo's behaviour. `src/address-plan.test.ts` is the recurrence gate — it fails if a zone leaves the supernet, or if any retired fleet prefix reappears anywhere in `packages/e2e`, `e2e/tests` or `modules/*/e2e`.
|
|
264
296
|
- **MCP server (agent-driven e2e)** — `packages/mcp-server/src/index.ts` — stdio MCP server exposing `start_run`/`run_status`/`run_result`/`stop_run` (detached cele2e runs read off the event bus, no ANSI scraping) + `env_check` (docker VM, run-lock, shared-infra, mgmt image CLI version, netapps). Dev/ops tool, `private`, not shipped to consumers.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Module pause/unpause (openspec/changes/module-pause-lifecycle).
|
|
2
|
+
--
|
|
3
|
+
-- Purely additive: `state` gains a new legal value 'PAUSED' (no constraint to
|
|
4
|
+
-- alter — the column is free text typed only in TypeScript), plus two nullable
|
|
5
|
+
-- columns. Every existing module is simply not paused, so there is nothing to
|
|
6
|
+
-- backfill.
|
|
7
|
+
--
|
|
8
|
+
-- `paused_at` is not decoration. A pause suppresses the alerting that would
|
|
9
|
+
-- otherwise report the module as down, so the paused-ness itself has to be
|
|
10
|
+
-- loud; the DURATION is what distinguishes a maintenance window from an outage
|
|
11
|
+
-- someone forgot about, and `state` alone cannot answer it (design D7).
|
|
12
|
+
--
|
|
13
|
+
-- Note for rollback: an older CLI reading state='PAUSED' hits a value outside
|
|
14
|
+
-- its union and fails to recognise it, rather than reading the module as live.
|
|
15
|
+
-- Loud beats silent — but it still has no `unpause`, so unpause everything
|
|
16
|
+
-- before downgrading.
|
|
17
|
+
|
|
18
|
+
ALTER TABLE `modules` ADD `paused_at` integer;--> statement-breakpoint
|
|
19
|
+
ALTER TABLE `modules` ADD `pause_reason` text;--> statement-breakpoint
|
|
20
|
+
CREATE INDEX `modules_state_idx` ON `modules` (`state`);
|
|
@@ -169,6 +169,13 @@
|
|
|
169
169
|
"when": 1783900000000,
|
|
170
170
|
"tag": "0023_public_dns_evidence",
|
|
171
171
|
"breakpoints": true
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"idx": 24,
|
|
175
|
+
"version": "6",
|
|
176
|
+
"when": 1784000000000,
|
|
177
|
+
"tag": "0024_module_pause",
|
|
178
|
+
"breakpoints": true
|
|
172
179
|
}
|
|
173
180
|
]
|
|
174
|
-
}
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,11 +57,10 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
|
-
"@celilo/capabilities": "^1.
|
|
61
|
-
"@celilo/cli-display": "^0.
|
|
62
|
-
"@celilo/core": "^0.
|
|
63
|
-
"@celilo/event-bus": "^0.
|
|
64
|
-
"@clack/prompts": "^1.1.0",
|
|
60
|
+
"@celilo/capabilities": "^1.1.0",
|
|
61
|
+
"@celilo/cli-display": "^0.2.0",
|
|
62
|
+
"@celilo/core": "^0.7.0",
|
|
63
|
+
"@celilo/event-bus": "^0.4.0",
|
|
65
64
|
"ajv": "^8.18.0",
|
|
66
65
|
"drizzle-orm": "^0.36.4",
|
|
67
66
|
"ink": "^7.0.1",
|
|
@@ -111,7 +111,11 @@ describe('Container Services and Machine Pool CLI Integration', () => {
|
|
|
111
111
|
// Remove service with --force flag to skip confirmation
|
|
112
112
|
const output = runCli(ctx.cli, 'service remove service-to-remove --force');
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// The command's RESULT, which is what stdout carries. This used to assert
|
|
115
|
+
// on the lowercase "removed" of the `celiloOutro` banner, which clack put
|
|
116
|
+
// on stdout while putting the result on stderr; celilo#699 swapped those
|
|
117
|
+
// so stdout is the data channel and the banner is chrome on stderr.
|
|
118
|
+
expect(output).toContain('Removed service: service-to-remove');
|
|
115
119
|
|
|
116
120
|
// Verify deletion
|
|
117
121
|
const services = await db.select().from(containerServices).all();
|
|
@@ -236,7 +240,9 @@ describe('Container Services and Machine Pool CLI Integration', () => {
|
|
|
236
240
|
// Remove machine with --force flag to skip confirmation
|
|
237
241
|
const output = runCli(ctx.cli, 'machine remove remove-me --force');
|
|
238
242
|
|
|
239
|
-
|
|
243
|
+
// See the service-remove case above: stdout now carries the result, not
|
|
244
|
+
// the outro banner (celilo#699).
|
|
245
|
+
expect(output).toContain('Removed machine: remove-me');
|
|
240
246
|
|
|
241
247
|
// Verify deletion
|
|
242
248
|
const machinesList = await db.select().from(machines).all();
|
|
@@ -93,11 +93,12 @@ test('renders a forwarded interview and sends the answer back', async () => {
|
|
|
93
93
|
/**
|
|
94
94
|
* Regression for the fabricated "operator declined".
|
|
95
95
|
*
|
|
96
|
-
* The default renderer prompts
|
|
97
|
-
* whether or not stdin
|
|
98
|
-
* stream) the next newline submitted the prompt at
|
|
99
|
-
* question's `defaultValue` —
|
|
100
|
-
* considered "no". With no terminal the client must
|
|
96
|
+
* The default renderer prompts on the terminal. The renderer it replaced read
|
|
97
|
+
* keypresses off stdin whether or not stdin was a terminal, so driven over the
|
|
98
|
+
* MCP (stdin = the JSON-RPC stream) the next newline submitted the prompt at
|
|
99
|
+
* its `initialValue` — the question's `defaultValue` — and a breaking update
|
|
100
|
+
* nobody saw came back as a considered "no". With no terminal the client must
|
|
101
|
+
* say it cannot answer.
|
|
101
102
|
*
|
|
102
103
|
* And it must say so as `unanswerable`, NOT as an `answer` of any shape: an
|
|
103
104
|
* `answer` is what consumes the query and destroys a question nobody decided
|
package/src/api/serve.ts
CHANGED
|
@@ -27,8 +27,10 @@ import {
|
|
|
27
27
|
} from '@celilo/core';
|
|
28
28
|
import { parseArguments } from '../cli/parser';
|
|
29
29
|
import { getEventBusPath } from '../config/paths';
|
|
30
|
+
import { getDb } from '../db/client';
|
|
30
31
|
import { isAuthorized } from '../services/api-access';
|
|
31
32
|
import { EVENT_TYPES } from '../services/bus-interview';
|
|
33
|
+
import { describePausedModule, listPausedModules } from '../services/module-pause';
|
|
32
34
|
import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
|
|
33
35
|
import {
|
|
34
36
|
SessionWriter,
|
|
@@ -50,6 +52,38 @@ function send(msg: ServerMessage): void {
|
|
|
50
52
|
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The fleet-level warnings stamped onto every terminal result (design D7).
|
|
57
|
+
*
|
|
58
|
+
* Deliberately unconditional and deliberately on UNRELATED commands: a pause
|
|
59
|
+
* suppresses its module's alerting, so the paused-ness itself is the only
|
|
60
|
+
* remaining signal, and a forgotten pause is found incidentally rather than by
|
|
61
|
+
* someone choosing to look. One indexed query (`modules_state_idx`), so this
|
|
62
|
+
* stays cheap enough to run on every call.
|
|
63
|
+
*
|
|
64
|
+
* Never throws: a broken or missing DB must not turn a working command into a
|
|
65
|
+
* failure over a warning.
|
|
66
|
+
*/
|
|
67
|
+
function fleetWarnings(): string[] {
|
|
68
|
+
try {
|
|
69
|
+
const paused = listPausedModules(getDb());
|
|
70
|
+
if (paused.length === 0) return [];
|
|
71
|
+
return [
|
|
72
|
+
`${paused.length} module(s) PAUSED: ${paused.map((m) => describePausedModule(m)).join(', ')}. A paused module is quiesced and its alerts are suppressed. Unpause with "celilo module unpause <id>".`,
|
|
73
|
+
];
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Terminal result, with the fleet warnings attached. */
|
|
80
|
+
function resultMessage(success: boolean, exitCode: number): ServerMessage {
|
|
81
|
+
const warnings = fleetWarnings();
|
|
82
|
+
return warnings.length > 0
|
|
83
|
+
? { type: 'result', success, exitCode, warnings }
|
|
84
|
+
: { type: 'result', success, exitCode };
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
function audit(principal: string, op: string, decision: string, exitCode?: number): void {
|
|
54
88
|
const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
|
|
55
89
|
process.stderr.write(
|
|
@@ -107,7 +141,7 @@ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void)
|
|
|
107
141
|
|
|
108
142
|
await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
|
|
109
143
|
const exitCode = await child.exited;
|
|
110
|
-
forward(
|
|
144
|
+
forward(resultMessage(exitCode === 0, exitCode));
|
|
111
145
|
return exitCode;
|
|
112
146
|
}
|
|
113
147
|
|
|
@@ -119,7 +153,7 @@ async function handleAttach(principal: string, sessionId: string): Promise<void>
|
|
|
119
153
|
const record = readSession(sessionId);
|
|
120
154
|
if (!record) {
|
|
121
155
|
send({ type: 'error', error: `no such session: ${sessionId}` });
|
|
122
|
-
send(
|
|
156
|
+
send(resultMessage(false, 1));
|
|
123
157
|
return;
|
|
124
158
|
}
|
|
125
159
|
if (record.principal !== principal) {
|
|
@@ -127,7 +161,7 @@ async function handleAttach(principal: string, sessionId: string): Promise<void>
|
|
|
127
161
|
type: 'error',
|
|
128
162
|
error: `permission denied: session ${sessionId} belongs to another principal`,
|
|
129
163
|
});
|
|
130
|
-
send(
|
|
164
|
+
send(resultMessage(false, EXIT_PERMISSION_DENIED));
|
|
131
165
|
audit(principal, `attach:${sessionId}`, 'deny');
|
|
132
166
|
return;
|
|
133
167
|
}
|
|
@@ -167,7 +201,7 @@ function handleCancel(principal: string, sessionId: string): void {
|
|
|
167
201
|
const record = readSession(sessionId);
|
|
168
202
|
if (!record) {
|
|
169
203
|
send({ type: 'error', error: `no such session: ${sessionId}` });
|
|
170
|
-
send(
|
|
204
|
+
send(resultMessage(false, 1));
|
|
171
205
|
return;
|
|
172
206
|
}
|
|
173
207
|
if (record.principal !== principal) {
|
|
@@ -175,7 +209,7 @@ function handleCancel(principal: string, sessionId: string): void {
|
|
|
175
209
|
type: 'error',
|
|
176
210
|
error: `permission denied: session ${sessionId} belongs to another principal`,
|
|
177
211
|
});
|
|
178
|
-
send(
|
|
212
|
+
send(resultMessage(false, EXIT_PERMISSION_DENIED));
|
|
179
213
|
audit(principal, `cancel:${sessionId}`, 'deny');
|
|
180
214
|
return;
|
|
181
215
|
}
|
|
@@ -185,7 +219,7 @@ function handleCancel(principal: string, sessionId: string): void {
|
|
|
185
219
|
emittedBy: `api:${principal}`,
|
|
186
220
|
});
|
|
187
221
|
audit(principal, `cancel:${sessionId}`, 'allow');
|
|
188
|
-
send(
|
|
222
|
+
send(resultMessage(true, 0));
|
|
189
223
|
}
|
|
190
224
|
|
|
191
225
|
export async function apiServeMode(principal: string): Promise<void> {
|
|
@@ -251,7 +285,7 @@ export async function apiServeMode(principal: string): Promise<void> {
|
|
|
251
285
|
|
|
252
286
|
if (!(await isAuthorized(principal, command, subcommand))) {
|
|
253
287
|
send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
|
|
254
|
-
send(
|
|
288
|
+
send(resultMessage(false, EXIT_PERMISSION_DENIED));
|
|
255
289
|
audit(principal, label, 'deny');
|
|
256
290
|
return;
|
|
257
291
|
}
|
|
@@ -636,6 +636,40 @@ export class ProxmoxClient {
|
|
|
636
636
|
if (!result.success) return result;
|
|
637
637
|
return { success: true, data: findNodeForVmid(result.data, vmid) };
|
|
638
638
|
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Power a guest down or up. Used by `module pause --stop-infra` (design D2),
|
|
642
|
+
* where stopping the box is opt-in rather than what a pause means.
|
|
643
|
+
*
|
|
644
|
+
* `shutdown` rather than `stop` on the way down: it asks the guest to go
|
|
645
|
+
* quietly instead of pulling its power, and a pause is a planned operation,
|
|
646
|
+
* not a fault. Proxmox returns a task id; callers that need to know it
|
|
647
|
+
* finished poll with `pollTaskUntilDone`.
|
|
648
|
+
*/
|
|
649
|
+
async setGuestPower(
|
|
650
|
+
vmid: number,
|
|
651
|
+
kind: 'lxc' | 'qemu',
|
|
652
|
+
power: 'shutdown' | 'start',
|
|
653
|
+
): Promise<ProxmoxResult<string>> {
|
|
654
|
+
const node = await this.nodeForVmid(vmid);
|
|
655
|
+
if (!node.success) return node;
|
|
656
|
+
if (!node.data) {
|
|
657
|
+
return { success: false, message: `No Proxmox node hosts vmid ${vmid}` };
|
|
658
|
+
}
|
|
659
|
+
return makeProxmoxPost<string>(
|
|
660
|
+
this.credentials,
|
|
661
|
+
`/nodes/${node.data}/${kind}/${vmid}/status/${power}`,
|
|
662
|
+
{},
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Current run state of a guest (`running`, `stopped`, …). */
|
|
667
|
+
async guestStatus(vmid: number): Promise<ProxmoxResult<string | null>> {
|
|
668
|
+
const result = await this.clusterResources();
|
|
669
|
+
if (!result.success) return result;
|
|
670
|
+
const guest = result.data.find((r) => r.vmid === vmid);
|
|
671
|
+
return { success: true, data: guest?.status ?? null };
|
|
672
|
+
}
|
|
639
673
|
}
|
|
640
674
|
|
|
641
675
|
/**
|
|
@@ -21,6 +21,7 @@ import type { SuppressionTopology } from '../../services/alerting/suppression';
|
|
|
21
21
|
import { runSweep } from '../../services/alerting/sweep-runner';
|
|
22
22
|
import { getModuleSystems } from '../../services/deployed-systems';
|
|
23
23
|
import { runModuleHealthCheck } from '../../services/health-runner';
|
|
24
|
+
import { listPausedModules } from '../../services/module-pause';
|
|
24
25
|
import type { CommandResult } from '../types';
|
|
25
26
|
|
|
26
27
|
/** Grace window before a newly-fired alert may notify. */
|
|
@@ -65,6 +66,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
65
66
|
},
|
|
66
67
|
loadTopology: () => loadTopology(db),
|
|
67
68
|
loadDeployWindowModules: () => modulesInDeployWindow(db),
|
|
69
|
+
loadPausedModules: () => new Set(listPausedModules(db).map((m) => m.id)),
|
|
68
70
|
isSuppressible: (alert) => suppressibleByMonitor.get(alert.monitorId) ?? true,
|
|
69
71
|
notifyDepsFor: (alert) => buildNotifyDeps(db, alert, new Date()),
|
|
70
72
|
now: () => new Date(),
|
|
@@ -4,10 +4,12 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
6
6
|
import {
|
|
7
|
+
type FailedDeliveryReport,
|
|
7
8
|
handleEventsAck,
|
|
8
9
|
handleEventsDrain,
|
|
9
10
|
handleEventsEmit,
|
|
10
11
|
handleEventsFail,
|
|
12
|
+
handleEventsListFailed,
|
|
11
13
|
handleEventsListPending,
|
|
12
14
|
handleEventsListSubscribers,
|
|
13
15
|
handleEventsRepair,
|
|
@@ -262,3 +264,67 @@ describe('celilo events command handlers', () => {
|
|
|
262
264
|
expect(replies[0].payload).toEqual({ value: 'a.net' });
|
|
263
265
|
});
|
|
264
266
|
});
|
|
267
|
+
|
|
268
|
+
describe('celilo events list-failed', () => {
|
|
269
|
+
let dir: string;
|
|
270
|
+
let dbPath: string;
|
|
271
|
+
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
dir = mkdtempSync(join(tmpdir(), 'events-failed-test-'));
|
|
274
|
+
dbPath = join(dir, 'events.db');
|
|
275
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
276
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
277
|
+
const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.*', handler: 'echo' });
|
|
278
|
+
for (let i = 0; i < 60; i++) {
|
|
279
|
+
const event = bus.emitRaw('ddns.refresh', { n: i });
|
|
280
|
+
bus.markFailed({ eventId: event.id, subscriberId: sub.id }, new Error('boom'), {
|
|
281
|
+
abandoned: true,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
bus.close();
|
|
285
|
+
});
|
|
286
|
+
afterEach(() => {
|
|
287
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
288
|
+
try {
|
|
289
|
+
rmSync(dir, { recursive: true, force: true });
|
|
290
|
+
} catch {
|
|
291
|
+
/* ignore */
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// The whole point of the command: `total` is a COUNT, `shown` is the limit.
|
|
296
|
+
it('separates the true total from the capped sample', async () => {
|
|
297
|
+
const result = await handleEventsListFailed([], { limit: '5' });
|
|
298
|
+
expect(result.success).toBe(true);
|
|
299
|
+
if (!result.success) throw new Error('expected success');
|
|
300
|
+
const report = result.data as FailedDeliveryReport;
|
|
301
|
+
expect(report.total).toBe(60);
|
|
302
|
+
expect(report.shown).toBe(5);
|
|
303
|
+
expect(report.deliveries).toHaveLength(5);
|
|
304
|
+
expect(report.bySubscriber).toEqual([
|
|
305
|
+
{ subscriber: 'namecheap.ddns', count: 60, latestFinishedAt: expect.any(Number) },
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('names the subscriber and event type on every row', async () => {
|
|
310
|
+
const result = await handleEventsListFailed([], { limit: '1' });
|
|
311
|
+
if (!result.success) throw new Error('expected success');
|
|
312
|
+
const row = (result.data as FailedDeliveryReport).deliveries[0];
|
|
313
|
+
expect(row.subscriber).toBe('namecheap.ddns');
|
|
314
|
+
expect(row.eventType).toBe('ddns.refresh');
|
|
315
|
+
expect(row.status).toBe('abandoned');
|
|
316
|
+
expect(row.error).toBe('boom');
|
|
317
|
+
expect(row.finishedAt).toBeGreaterThan(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('--subscriber scopes both the rows and the total', async () => {
|
|
321
|
+
const mine = await handleEventsListFailed([], { subscriber: 'namecheap.ddns', limit: '3' });
|
|
322
|
+
if (!mine.success) throw new Error('expected success');
|
|
323
|
+
expect((mine.data as FailedDeliveryReport).total).toBe(60);
|
|
324
|
+
|
|
325
|
+
const other = await handleEventsListFailed([], { subscriber: 'nobody' });
|
|
326
|
+
if (!other.success) throw new Error('expected success');
|
|
327
|
+
expect((other.data as FailedDeliveryReport).total).toBe(0);
|
|
328
|
+
expect((other.data as FailedDeliveryReport).deliveries).toHaveLength(0);
|
|
329
|
+
});
|
|
330
|
+
});
|