@celilo/cli 0.21.0 → 0.22.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.
@@ -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` (**greenwave** /
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
- - **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`.
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, `greenwave` does not).
30
+ - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,10 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^1.0.0",
60
+ "@celilo/capabilities": "^1.0.1",
61
61
  "@celilo/cli-display": "^0.1.10",
62
- "@celilo/core": "^0.5.0",
63
- "@celilo/event-bus": "^0.3.0",
62
+ "@celilo/core": "^0.6.0",
63
+ "@celilo/event-bus": "^0.4.0",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -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
+ });
@@ -10,6 +10,7 @@
10
10
  * tail recent events
11
11
  * list-subscribers persistent subscribers
12
12
  * list-pending pending deliveries (subscriber fan-out — NOT questions)
13
+ * list-failed failed/abandoned deliveries + a TRUE total
13
14
  * list-unanswered interview questions nobody has answered yet
14
15
  * drain process pending deliveries once
15
16
  * run long-running dispatcher (foreground; SIGINT to stop)
@@ -24,7 +25,9 @@
24
25
  import { spawnSync } from 'node:child_process';
25
26
  import {
26
27
  BUS_VERSION,
28
+ type FailedBySubscriber,
27
29
  defineEvents,
30
+ describeError,
28
31
  drainOnce,
29
32
  openBus,
30
33
  recoverFromCrash,
@@ -246,6 +249,75 @@ export async function handleEventsListPending(
246
249
  }
247
250
  }
248
251
 
252
+ /** One failed/abandoned delivery, as `events list-failed` reports it. */
253
+ export interface FailedDelivery {
254
+ eventId: number;
255
+ eventType: string | null;
256
+ subscriber: string | null;
257
+ status: string;
258
+ attempts: number;
259
+ finishedAt: number | null;
260
+ error: string | null;
261
+ }
262
+
263
+ /** What `events list-failed` returns: the true total, the shape, and a sample. */
264
+ export interface FailedDeliveryReport {
265
+ total: number;
266
+ bySubscriber: FailedBySubscriber[];
267
+ shown: number;
268
+ deliveries: FailedDelivery[];
269
+ }
270
+
271
+ /**
272
+ * `celilo events list-failed` — what has failed or been abandoned, who it was
273
+ * for, and why.
274
+ *
275
+ * The instrument celilo#623 lacked: `failedDeliveries` had exactly one caller
276
+ * (the doctor) and no operator surface at all, so the only signal was a count
277
+ * that was really a LIMIT. `total` here is a real COUNT — `deliveries` is the
278
+ * sample, capped by `--limit`, and `shown` says so.
279
+ *
280
+ * This is NOT `list-pending`, which reads deliveries still queued.
281
+ */
282
+ export async function handleEventsListFailed(
283
+ _args: string[],
284
+ flags: Record<string, string | boolean>,
285
+ ): Promise<CommandResult> {
286
+ const bus = openCliBus();
287
+ try {
288
+ const subscriber = typeof flags.subscriber === 'string' ? flags.subscriber : undefined;
289
+ const limit = flags.limit ? Number(flags.limit) : 50;
290
+ const { total, bySubscriber } = bus.failedDeliveryTotals();
291
+ // Subscriber/event names live in other tables; the delivery row has ids.
292
+ const named = new Map(
293
+ bus.db
294
+ .query<{ id: number; name: string }, []>('SELECT id, name FROM subscribers')
295
+ .all()
296
+ .map((r) => [r.id, r.name]),
297
+ );
298
+ const deliveries: FailedDelivery[] = bus.failedDeliveries({ limit, subscriber }).map((d) => ({
299
+ eventId: d.eventId,
300
+ eventType: bus.getEvent(d.eventId)?.type ?? null,
301
+ subscriber: named.get(d.subscriberId) ?? null,
302
+ status: d.status,
303
+ attempts: d.attempts,
304
+ finishedAt: d.finishedAt,
305
+ error: describeError(d.lastError),
306
+ }));
307
+ const report: FailedDeliveryReport = {
308
+ total: subscriber
309
+ ? (bySubscriber.find((s) => s.subscriber === subscriber)?.count ?? 0)
310
+ : total,
311
+ bySubscriber,
312
+ shown: deliveries.length,
313
+ deliveries,
314
+ };
315
+ return jsonResult(report);
316
+ } finally {
317
+ bus.close();
318
+ }
319
+ }
320
+
249
321
  /** One unanswered interview question, as `events list-unanswered` reports it. */
250
322
  export interface UnansweredInterview {
251
323
  eventId: number;
@@ -113,6 +113,7 @@ export async function getCompletions(words: string[], current: number): Promise<
113
113
  'list-subscribers',
114
114
  'resync-subscriptions',
115
115
  'list-pending',
116
+ 'list-failed',
116
117
  'list-unanswered',
117
118
  'drain',
118
119
  'run',
package/src/cli/index.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  handleEventsEmit,
33
33
  handleEventsFail,
34
34
  handleEventsInstallDaemon,
35
+ handleEventsListFailed,
35
36
  handleEventsListPending,
36
37
  handleEventsListSubscribers,
37
38
  handleEventsListUnanswered,
@@ -300,6 +301,7 @@ Subcommands:
300
301
  list-subscribers List persistent bus subscribers
301
302
  resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
302
303
  list-pending [--subscriber] List pending deliveries
304
+ list-failed [--subscriber] List failed/abandoned deliveries with a true total
303
305
  drain [--concurrency N] Process pending deliveries once and return
304
306
  run [--poll-ms N] Run the long-running dispatcher (foreground)
305
307
  emit <type> [<payload>] Emit an event (operator/test path)
@@ -1425,6 +1427,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1425
1427
  return handleEventsResyncSubscriptions();
1426
1428
  case 'list-pending':
1427
1429
  return handleEventsListPending(parsed.args, parsed.flags);
1430
+ case 'list-failed':
1431
+ return handleEventsListFailed(parsed.args, parsed.flags);
1428
1432
  case 'list-unanswered':
1429
1433
  return handleEventsListUnanswered(parsed.args, parsed.flags);
1430
1434
  case 'drain':
@@ -40,8 +40,20 @@ export interface DnsRegistrationRow {
40
40
  * triggers for FK-cascaded deletes only when `recursive_triggers` is on,
41
41
  * and reads are the only thing that consumes the ledger. Move it into a
42
42
  * trigger if something ever reads these rows without going through here.
43
+ *
44
+ * It CHECKS before deleting, so the overwhelmingly common case (nothing
45
+ * orphaned) stays a pure read. The first version ran the DELETE
46
+ * unconditionally, which took a write lock on every list — including the
47
+ * refresh hook's, and `celilo dns registrations`. A read that quietly writes
48
+ * is a surprise on its own, and on SQLite it is a surprise that serialises
49
+ * against every other writer for no benefit.
43
50
  */
44
51
  function pruneOrphanedRegistrations(db: DbClient): void {
52
+ const orphaned = sql`SELECT 1 FROM dns_registrations WHERE NOT EXISTS (
53
+ SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
54
+ ) LIMIT 1`;
55
+ if (!db.get(orphaned)) return;
56
+
45
57
  db.run(
46
58
  sql`DELETE FROM dns_registrations WHERE NOT EXISTS (
47
59
  SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
@@ -42,6 +42,19 @@ function seedHeartbeat(
42
42
  );
43
43
  }
44
44
 
45
+ /** Abandon `count` deliveries to one subscriber, oldest first. */
46
+ function seedFailed(bus: Bus, count: number): void {
47
+ const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.refresh', handler: 'echo' });
48
+ for (let i = 0; i < count; i++) {
49
+ const event = bus.emitRaw('ddns.refresh', { n: i });
50
+ bus.markFailed(
51
+ { eventId: event.id, subscriberId: sub.id },
52
+ new Error('handler timed out after 30000ms'),
53
+ { abandoned: true },
54
+ );
55
+ }
56
+ }
57
+
45
58
  /** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
46
59
  function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
47
60
  const path = getDaemonUnitPath('linux', home, scope, systemRoot);
@@ -199,6 +212,39 @@ describe('checkDispatcher', () => {
199
212
  expect(f.status).toBe('warn');
200
213
  expect(f.detail.join(' ')).toContain('not emitting on schedule');
201
214
  });
215
+
216
+ // celilo#623 — the old check read `failedDeliveries({ limit: 50 }).length`,
217
+ // so on celilo-mgr it printed a literal `50` that meant "at least 50" and
218
+ // read as an exact count. 137 > any limit anyone would pick.
219
+ it('reports the TRUE total of failed deliveries, not the read limit', () => {
220
+ seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
221
+ installFakeUnit(home);
222
+ seedFailed(bus, 137);
223
+
224
+ const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
225
+ expect(f.status).toBe('warn');
226
+ expect(f.detail.join(' ')).toContain('137 failed/abandoned delivery(ies) total');
227
+ expect(f.detail.join(' ')).not.toContain('50 failed');
228
+ });
229
+
230
+ // The stored error is double-wrapped for every row written before the
231
+ // serializeError fix; those live 90 days, so doctor must unwrap them.
232
+ it('renders the sample error text, not {"message":"[object Object]"}', () => {
233
+ seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
234
+ installFakeUnit(home);
235
+ seedFailed(bus, 1);
236
+ bus.db.run('UPDATE deliveries SET last_error = ?', [
237
+ JSON.stringify({
238
+ message: '[object Object]',
239
+ value: { message: 'handler exited with code 1' },
240
+ }),
241
+ ]);
242
+
243
+ const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
244
+ const detail = f.detail.join(' ');
245
+ expect(detail).toContain('handler exited with code 1');
246
+ expect(detail).not.toContain('[object Object]');
247
+ });
202
248
  });
203
249
 
204
250
  describe('checkSubscribers + checkCapabilityProviders', () => {
@@ -17,7 +17,7 @@
17
17
  * rendering + `--fix` orchestration lives in the doctor command.
18
18
  */
19
19
 
20
- import type { Bus } from '@celilo/event-bus';
20
+ import { type Bus, describeError } from '@celilo/event-bus';
21
21
  import { inArray } from 'drizzle-orm';
22
22
  import { getModuleStoragePath } from '../config/paths';
23
23
  import { type DbClient, findMigrationsFolder } from '../db/client';
@@ -343,12 +343,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
343
343
  }
344
344
  }
345
345
 
346
- const failed = bus.failedDeliveries({ limit: 50 });
347
- if (failed.length > 0) {
346
+ // A TRUE total, not `failedDeliveries().length` that saturates at its LIMIT
347
+ // and printed a literal `50` on celilo-mgr that read as a count (celilo#623).
348
+ // The newest row dates the backlog: a big total whose newest entry is days old
349
+ // is drained history, not active bleeding.
350
+ const { total: failedTotal } = bus.failedDeliveryTotals();
351
+ if (failedTotal > 0) {
348
352
  statuses.push('warn');
349
- const sample = failed[0]?.lastError ? ` (e.g. ${failed[0].lastError.split('\n')[0]})` : '';
350
- detail.push(`${failed.length} failed/abandoned delivery(ies)${sample}`);
351
- remediations.push('inspect failed deliveries and re-emit/repair as needed');
353
+ const newest = bus.failedDeliveries({ limit: 1 })[0];
354
+ const age = newest?.finishedAt
355
+ ? `, most recent ${Math.round((now - newest.finishedAt) / 60000)}min ago`
356
+ : '';
357
+ detail.push(`${failedTotal} failed/abandoned delivery(ies) total${age}`);
358
+ const sample = describeError(newest?.lastError ?? null)?.split('\n')[0];
359
+ if (sample) detail.push(` example (newest, not the only one): ${sample}`);
360
+ remediations.push('`celilo events list-failed` to see them; re-emit/repair as needed');
352
361
  }
353
362
 
354
363
  const status = worst(statuses);