@celilo/cli 0.19.0 → 0.21.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.
Files changed (57) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
  4. package/drizzle/0021_dns_registration_consumers.sql +63 -0
  5. package/drizzle/0022_dns_registrations_companion.sql +15 -0
  6. package/drizzle/0023_public_dns_evidence.sql +19 -0
  7. package/drizzle/meta/_journal.json +29 -1
  8. package/package.json +4 -4
  9. package/schemas/system_config.json +22 -11
  10. package/src/api/remote-client.test.ts +34 -12
  11. package/src/api/serve.ts +234 -38
  12. package/src/api/sessions.test.ts +196 -0
  13. package/src/api/sessions.ts +278 -0
  14. package/src/cli/commands/backup-sweep.ts +25 -9
  15. package/src/cli/commands/dns.ts +8 -4
  16. package/src/cli/commands/events.ts +64 -5
  17. package/src/cli/commands/module-update.ts +34 -11
  18. package/src/cli/commands/system-audit.ts +15 -0
  19. package/src/cli/commands/system-migrate.test.ts +25 -4
  20. package/src/cli/commands/system-update.ts +5 -0
  21. package/src/cli/completion.ts +1 -0
  22. package/src/cli/index.ts +22 -2
  23. package/src/cli/tui/audit-state.ts +2 -0
  24. package/src/db/dns-registrations-migration.test.ts +205 -0
  25. package/src/db/schema.ts +77 -8
  26. package/src/hooks/define-hook.test.ts +3 -3
  27. package/src/hooks/executor.test.ts +58 -0
  28. package/src/hooks/executor.ts +67 -7
  29. package/src/hooks/run-named-hook.ts +7 -1
  30. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  31. package/src/module/packaging/build.ts +14 -0
  32. package/src/services/alerting/builtin-monitors.ts +3 -0
  33. package/src/services/alerting/builtin-source.ts +23 -0
  34. package/src/services/audit/index.test.ts +2 -0
  35. package/src/services/audit/index.ts +3 -0
  36. package/src/services/audit/public-dns-source.ts +55 -0
  37. package/src/services/audit/public-dns.test.ts +209 -0
  38. package/src/services/audit/public-dns.ts +286 -0
  39. package/src/services/audit/types.ts +1 -0
  40. package/src/services/backup-metadata.ts +17 -0
  41. package/src/services/backup-staging.test.ts +98 -0
  42. package/src/services/backup-staging.ts +73 -1
  43. package/src/services/backup-sweep.test.ts +15 -0
  44. package/src/services/backup-sweep.ts +17 -1
  45. package/src/services/bus-interview-park.test.ts +179 -0
  46. package/src/services/bus-interview.ts +13 -8
  47. package/src/services/dns-registrations.test.ts +78 -16
  48. package/src/services/dns-registrations.ts +107 -19
  49. package/src/services/fleet-checks.test.ts +47 -1
  50. package/src/services/fleet-checks.ts +36 -4
  51. package/src/services/interview-errors.ts +24 -7
  52. package/src/services/module-subscriptions.test.ts +9 -0
  53. package/src/services/public-dns-probe.test.ts +81 -0
  54. package/src/services/public-dns-probe.ts +156 -0
  55. package/src/services/remote-responder.test.ts +33 -20
  56. package/src/services/remote-responder.ts +10 -6
  57. package/src/services/update/orchestrator.test.ts +2 -0
@@ -78,12 +78,18 @@ test('answers responder.probe with kind daemon', async () => {
78
78
  }, 15_000);
79
79
 
80
80
  /**
81
- * When the client can't reach a decider it says so. The responder must relay
82
- * that as an error reply — the waiting command then fails loudly instead of the
83
- * question quietly resolving to `defaultValue`, which is how a breaking update
84
- * came to be recorded as "operator declined".
81
+ * A responder that cannot reach a decider must emit NOTHING (celilo#609).
82
+ *
83
+ * PR #607 had it reply `{error}` here, which reads as "fail loudly" but carries
84
+ * `replyFor: <query id>` it *consumes the query*. The question then no longer
85
+ * exists for anyone else to answer, and the command dies with it. Leaving it
86
+ * unanswered parks the command instead, so a later responder can still decide.
87
+ *
88
+ * Asserted on the value that comes back, not on liveness: the reply emitted
89
+ * afterwards correlates to the original query, which is only true if the
90
+ * responder left it standing.
85
91
  */
86
- test('an ask that cannot be answered replies with an error, not the default', async () => {
92
+ test('an ask that cannot be answered emits no reply the query stays answerable', async () => {
87
93
  const responder = startRemoteResponder({
88
94
  busDbPath,
89
95
  ask: async () => {
@@ -92,24 +98,31 @@ test('an ask that cannot be answered replies with an error, not the default', as
92
98
  });
93
99
 
94
100
  const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
101
+ const type = 'interview.required.module-upgrade:iptables.apply_breaking';
95
102
  try {
96
- const replies = (await bus.query(
97
- 'interview.required.module-upgrade:iptables.apply_breaking' as never,
98
- {
99
- scope: 'module-upgrade:iptables',
100
- key: 'apply_breaking',
101
- kind: 'confirm',
102
- message: 'Apply breaking update for iptables?',
103
- required: true,
104
- defaultValue: 'false',
105
- } as never,
106
- { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
107
- )) as BusEvent[];
103
+ const query = bus.emitRaw(type, {
104
+ scope: 'module-upgrade:iptables',
105
+ key: 'apply_breaking',
106
+ kind: 'confirm',
107
+ message: 'Apply breaking update for iptables?',
108
+ required: true,
109
+ defaultValue: 'false',
110
+ });
111
+
112
+ // Well past the responder's watch latency: still nobody has answered.
113
+ await new Promise((r) => setTimeout(r, 600));
114
+ expect(bus.recentEvents({ type: `${type}.reply` })).toHaveLength(0);
108
115
 
116
+ // And the query is still live: a reply emitted now is a genuine answer.
117
+ bus.emitRaw(
118
+ `${type}.reply`,
119
+ { value: false },
120
+ { replyFor: query.id, emittedBy: 'later-responder' },
121
+ );
122
+ const replies = bus.recentEvents({ type: `${type}.reply` });
109
123
  expect(replies).toHaveLength(1);
110
- const payload = replies[0].payload as { value?: unknown; error?: string };
111
- expect(payload.value).toBeUndefined();
112
- expect(payload.error).toContain('terminal');
124
+ expect((replies[0].payload as { value: unknown }).value).toBe(false);
125
+ expect(replies[0].replyFor).toBe(query.id);
113
126
  } finally {
114
127
  bus.close();
115
128
  responder.close();
@@ -72,12 +72,16 @@ export function startRemoteResponder(opts: RemoteResponderOptions): RemoteRespon
72
72
  required: payload.required,
73
73
  });
74
74
  } catch (err) {
75
- // The client had no way to answer. Reply with the reason so the waiting
76
- // command fails loudly — never let the question decay to its default.
77
- bus.emitRaw(
78
- `${event.type}.reply`,
79
- { error: err instanceof Error ? err.message : String(err) },
80
- { replyFor: event.id, emittedBy: me },
75
+ // The client had no way to answer so we emit NOTHING. A reply of any
76
+ // shape consumes the query (it carries `replyFor: event.id`), destroying
77
+ // a question nobody has answered yet; the asking command then dies with
78
+ // it and no other responder can ever act. Leaving it unanswered parks the
79
+ // command instead, which is what `busInterview`'s `timeoutMs: 0` is for.
80
+ // The client learns it is parked from the `blocked` wire message.
81
+ process.stderr.write(
82
+ `[remote-responder] parked ${event.type} (#${event.id}): ${
83
+ err instanceof Error ? err.message : String(err)
84
+ }\n`,
81
85
  );
82
86
  return;
83
87
  }
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import type { DbClient } from '../../db/client';
3
3
  import type { ModuleManifest } from '../../manifest/schema';
4
4
  import type { AuditDeps } from '../audit';
5
+ import { unusedPublicDnsProbe } from '../audit/public-dns';
5
6
  import { buildModuleGraph } from './dep-graph';
6
7
  import {
7
8
  type ModuleSnapshot,
@@ -85,6 +86,7 @@ const cleanAudit: AuditDeps = {
85
86
  machinesReachable: { results: [] },
86
87
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
87
88
  trustedSources: { firewalls: [] },
89
+ publicDns: { records: [], probe: unusedPublicDnsProbe },
88
90
  };
89
91
 
90
92
  describe('consumerSkipReason', () => {