@celilo/cli 0.20.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.
Files changed (42) hide show
  1. package/CELILO_CORE_MODULES.md +7 -6
  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/cli/commands/dns.ts +8 -4
  11. package/src/cli/commands/events.test.ts +66 -0
  12. package/src/cli/commands/events.ts +76 -1
  13. package/src/cli/commands/system-audit.ts +15 -0
  14. package/src/cli/commands/system-migrate.test.ts +25 -4
  15. package/src/cli/commands/system-update.ts +5 -0
  16. package/src/cli/completion.ts +1 -0
  17. package/src/cli/index.ts +4 -0
  18. package/src/cli/tui/audit-state.ts +2 -0
  19. package/src/db/dns-registrations-migration.test.ts +205 -0
  20. package/src/db/schema.ts +77 -8
  21. package/src/hooks/define-hook.test.ts +3 -3
  22. package/src/hooks/executor.test.ts +58 -0
  23. package/src/hooks/executor.ts +67 -7
  24. package/src/hooks/run-named-hook.ts +7 -1
  25. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  26. package/src/module/packaging/build.ts +14 -0
  27. package/src/services/alerting/builtin-monitors.ts +3 -0
  28. package/src/services/alerting/builtin-source.ts +23 -0
  29. package/src/services/audit/index.test.ts +2 -0
  30. package/src/services/audit/index.ts +3 -0
  31. package/src/services/audit/public-dns-source.ts +55 -0
  32. package/src/services/audit/public-dns.test.ts +209 -0
  33. package/src/services/audit/public-dns.ts +286 -0
  34. package/src/services/audit/types.ts +1 -0
  35. package/src/services/dns-registrations.test.ts +78 -16
  36. package/src/services/dns-registrations.ts +119 -19
  37. package/src/services/fleet-checks.test.ts +93 -1
  38. package/src/services/fleet-checks.ts +51 -10
  39. package/src/services/module-subscriptions.test.ts +9 -0
  40. package/src/services/public-dns-probe.test.ts +81 -0
  41. package/src/services/public-dns-probe.ts +156 -0
  42. package/src/services/update/orchestrator.test.ts +2 -0
@@ -33,6 +33,12 @@ export interface RunNamedHookOptions {
33
33
  debug?: boolean;
34
34
  /** Hook inputs (key=value pairs from the CLI, etc.). Default: empty. */
35
35
  inputs?: Record<string, unknown>;
36
+ /**
37
+ * Total timeout for this invocation (ms). A bus delivery passes the
38
+ * subscription's declared `timeout_ms` (celilo#622); omitted, the manifest
39
+ * hook's own `timeout` or the executor default applies.
40
+ */
41
+ timeoutMs?: number;
36
42
  }
37
43
 
38
44
  export interface RunNamedHookResult extends HookResult {
@@ -138,7 +144,6 @@ export async function runNamedHook(
138
144
  if (hookName === 'refresh_registrations') {
139
145
  const registrations = listDnsRegistrations(db, { providerModuleId: moduleId }).map((r) => ({
140
146
  fqdn: r.fqdn,
141
- ip: r.ip,
142
147
  }));
143
148
  inputs = { ...inputs, registrations };
144
149
  }
@@ -157,6 +162,7 @@ export async function runNamedHook(
157
162
  capabilities: capabilityFunctions,
158
163
  requiredCapabilities,
159
164
  systems: getModuleSystems(moduleId, db),
165
+ timeoutMs: options.timeoutMs,
160
166
  },
161
167
  );
162
168
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Test fixture: a hook that logs once and then works in silence, the shape
3
+ * that celilo#622 is about (namecheap's ddns refresh logs per name, so one
4
+ * slow upstream response is a long gap between log lines).
5
+ *
6
+ * Sleeps `ctx.config.silent_ms` so a test can put the silence on either side
7
+ * of an idle bound.
8
+ */
9
+
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'container_created',
14
+ requires: [],
15
+ handler: async (ctx) => {
16
+ ctx.logger.info('Starting, then going quiet');
17
+ await new Promise((r) => setTimeout(r, Number(ctx.config.silent_ms ?? 0)));
18
+ return {};
19
+ },
20
+ });
@@ -210,6 +210,20 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
210
210
  console.log('Staging source for build (no package.json, using file copy)...');
211
211
  cpSync(sourceDir, buildDir, {
212
212
  recursive: true,
213
+ // MATERIALISE symlinks. `modules/*/scripts` depends on
214
+ // `@celilo/capabilities` by `file:` path so an in-repo module builds
215
+ // against the LIVE workspace rather than the last published tarball —
216
+ // without which a module cannot consume a capability added in the same
217
+ // PR, and `check:modules` typechecks every module against a version
218
+ // that no longer matches the source it ships beside.
219
+ //
220
+ // bun installs a `file:` dep as a tree of symlinks into the monorepo.
221
+ // Copied as symlinks they would point at a path that does not exist on
222
+ // the target, and because `scripts/node_modules` would still LOOK
223
+ // present, `installScriptDependencies` skips its `bun install` and the
224
+ // hooks fail at runtime with unresolvable imports. Dereferencing here
225
+ // is what keeps the shipped closure a real, self-contained tree.
226
+ dereference: true,
213
227
  filter: (src) => !shouldExclude(relative(sourceDir, src)),
214
228
  });
215
229
  }
@@ -25,6 +25,9 @@ import { type FailingKey, builtinAlertKey } from './keys';
25
25
  const TARGET_KIND_BY_CATEGORY: Partial<Record<DriftCategory, string>> = {
26
26
  machines_reachable: 'machine',
27
27
  disk_space: 'machine',
28
+ // Subject is an FQDN, which is neither a machine nor a module: a served
29
+ // name can outlive any single module that asked for it.
30
+ public_dns: 'hostname',
28
31
  transport_reads: 'module',
29
32
  services_reachable: 'service',
30
33
  services_credentials: 'service',
@@ -18,9 +18,16 @@ import { loadBackupAuditInfo } from '../audit/backup-source';
18
18
  import { auditBackups } from '../audit/backups';
19
19
  import { auditDiskSpace } from '../audit/disk-space';
20
20
  import { auditMachinesReachable } from '../audit/machines-reachable';
21
+ import { auditPublicDns } from '../audit/public-dns';
22
+ import {
23
+ loadPublicDnsEvidence,
24
+ loadPublicDnsRecords,
25
+ savePublicDnsEvidence,
26
+ } from '../audit/public-dns-source';
21
27
  import type { DriftCategory, DriftFinding } from '../audit/types';
22
28
  import { probeDiskUsage } from '../disk-probe';
23
29
  import { probeMachines } from '../machine-probe';
30
+ import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../public-dns-probe';
24
31
 
25
32
  /** Categories a monitor can currently schedule. */
26
33
  export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
@@ -28,6 +35,7 @@ export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
28
35
  'backups',
29
36
  'disk_space',
30
37
  'abandoned_operations',
38
+ 'public_dns',
31
39
  ];
32
40
 
33
41
  export function isSchedulableBuiltin(category: string): category is DriftCategory {
@@ -58,6 +66,21 @@ export async function runBuiltinCheckForMonitor(
58
66
  return auditAbandonedOperations({ records: loadAbandonedOperations(db) });
59
67
  }
60
68
 
69
+ // One DNS query per ledger name plus one HTTP echo, all bounded — cheap
70
+ // enough for a scheduled check, and the only one of these that looks at the
71
+ // fleet from OUTSIDE. It is the only place `undetermined` counters advance,
72
+ // which is why the check has to be scheduled and not merely available to
73
+ // `celilo system audit`: consecutive absence of evidence is a finding, and
74
+ // nothing is consecutive if it only runs when an operator asks.
75
+ if (category === 'public_dns') {
76
+ return auditPublicDns({
77
+ records: loadPublicDnsRecords(db),
78
+ probe: createPublicDnsProbe(loadPublicDnsProbeSettings(db)),
79
+ evidence: loadPublicDnsEvidence(db),
80
+ saveEvidence: (evidence) => savePublicDnsEvidence(db, evidence),
81
+ });
82
+ }
83
+
61
84
  throw new Error(
62
85
  `Built-in check "${category}" is not schedulable yet. Schedulable: ${SCHEDULABLE_BUILTIN_CHECKS.join(', ')}.`,
63
86
  );
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import type { DbClient } from '../../db/client';
3
3
  import { runAudit } from './index';
4
+ import { unusedPublicDnsProbe } from './public-dns';
4
5
 
5
6
  const fakeDb = {} as DbClient;
6
7
 
@@ -35,6 +36,7 @@ const emptyDeps = {
35
36
  machinesReachable: { results: [] },
36
37
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
37
38
  trustedSources: { firewalls: [] },
39
+ publicDns: { records: [], probe: unusedPublicDnsProbe },
38
40
  };
39
41
 
40
42
  describe('runAudit', () => {
@@ -21,6 +21,7 @@ import { type HealthAuditDeps, auditHealth } from './health';
21
21
  import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
22
22
  import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
23
23
  import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
24
+ import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
24
25
  import { type SchemaAuditDeps, auditSchema } from './schema';
25
26
  import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
26
27
  import {
@@ -59,6 +60,7 @@ export interface AuditDeps {
59
60
  secretsDecryptable: SecretsDecryptableAuditDeps;
60
61
  servicesReachable: ServicesReachableAuditDeps;
61
62
  machinesReachable: MachinesReachableAuditDeps;
63
+ publicDns: PublicDnsAuditDeps;
62
64
  transportReads: TransportReadsAuditDeps;
63
65
  trustedSources: TrustedSourcesAuditDeps;
64
66
  /** Defaults to `Date.now()`-based ISO string. */
@@ -114,6 +116,7 @@ export async function runAudit(
114
116
  wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
115
117
  wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
116
118
  wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
119
+ wrap('public_dns', auditPublicDns(deps.publicDns)),
117
120
  wrap('transport_reads', auditTransportReads(deps.transportReads)),
118
121
  wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
119
122
  ]);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * DB-facing half of the `public_dns` check: the ledger names it watches and
3
+ * the consecutive-undetermined counters it carries between runs.
4
+ *
5
+ * Split from `public-dns.ts` so the audit itself stays a pure function over an
6
+ * injected probe — the counters are the only state it has, and a check that
7
+ * owned its own storage could not be tested without one.
8
+ */
9
+
10
+ import type { DbClient } from '../../db/client';
11
+ import { publicDnsEvidence } from '../../db/schema';
12
+ import { listDnsRegistrations } from '../dns-registrations';
13
+ import type { PublicDnsEvidence, PublicDnsRecord } from './public-dns';
14
+
15
+ /**
16
+ * Every name celilo has asserted publicly. `lastAssertedAt` is when the fleet
17
+ * last told the registrar about it — the point the record's own TTL is
18
+ * measured from, so a divergence inside one TTL reads as propagation.
19
+ */
20
+ export function loadPublicDnsRecords(db: DbClient): PublicDnsRecord[] {
21
+ return listDnsRegistrations(db).map((row) => ({
22
+ fqdn: row.fqdn,
23
+ companion: row.companion,
24
+ lastAssertedAt: row.refreshedAt ?? row.registeredAt,
25
+ }));
26
+ }
27
+
28
+ export function loadPublicDnsEvidence(db: DbClient): PublicDnsEvidence[] {
29
+ return db
30
+ .select({
31
+ subject: publicDnsEvidence.subject,
32
+ undeterminedRuns: publicDnsEvidence.undeterminedRuns,
33
+ })
34
+ .from(publicDnsEvidence)
35
+ .all();
36
+ }
37
+
38
+ /**
39
+ * Replace the whole set. A subject that produced evidence this run is absent
40
+ * from `evidence` and its counter goes with it — that is what makes the count
41
+ * consecutive rather than cumulative.
42
+ */
43
+ export function savePublicDnsEvidence(db: DbClient, evidence: PublicDnsEvidence[]): void {
44
+ db.delete(publicDnsEvidence).run();
45
+ if (evidence.length === 0) return;
46
+ db.insert(publicDnsEvidence)
47
+ .values(
48
+ evidence.map((e) => ({
49
+ subject: e.subject,
50
+ undeterminedRuns: e.undeterminedRuns,
51
+ lastCheckedAt: new Date(),
52
+ })),
53
+ )
54
+ .run();
55
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The recurrence gate celilo#626 asks for, at the layer it can actually be
3
+ * asserted: feed the check a public record that diverges from where the fleet
4
+ * really is, and it must say so.
5
+ *
6
+ * Every check celilo had answered "healthy" for the nine days five public
7
+ * names were dark. This is the one that could not have.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import {
12
+ type IngressObservation,
13
+ type PublicDnsEvidence,
14
+ type PublicDnsProbe,
15
+ type PublicResolution,
16
+ auditPublicDns,
17
+ } from './public-dns';
18
+
19
+ const FLEET_INGRESS = '71.36.123.107';
20
+ /** The address the fleet moved off, and kept publishing for nine days. */
21
+ const DEAD_ADDRESS = '71.36.112.98';
22
+
23
+ const LONG_AGO = new Date('2026-08-01T00:00:00Z');
24
+ const NOW = new Date('2026-08-06T00:00:00Z');
25
+
26
+ function probe(options: {
27
+ ingress?: IngressObservation;
28
+ answers?: Record<string, PublicResolution>;
29
+ fallback?: PublicResolution;
30
+ }): PublicDnsProbe {
31
+ return {
32
+ resolver: '1.1.1.1',
33
+ echoService: 'https://echo.example',
34
+ async observeIngress() {
35
+ return options.ingress ?? { kind: 'observed', ip: FLEET_INGRESS };
36
+ },
37
+ async resolve(fqdn) {
38
+ return (
39
+ options.answers?.[fqdn] ??
40
+ options.fallback ?? { kind: 'answer', ip: FLEET_INGRESS, ttlSeconds: 180 }
41
+ );
42
+ },
43
+ };
44
+ }
45
+
46
+ function declared(fqdn: string, lastAssertedAt = LONG_AGO) {
47
+ return { fqdn, companion: false, lastAssertedAt };
48
+ }
49
+
50
+ describe('auditPublicDns', () => {
51
+ test('a record pointing at an address the fleet no longer holds is a finding', async () => {
52
+ const findings = await auditPublicDns({
53
+ records: [declared('apt.celilo.computer')],
54
+ probe: probe({
55
+ answers: {
56
+ 'apt.celilo.computer': { kind: 'answer', ip: DEAD_ADDRESS, ttlSeconds: 180 },
57
+ },
58
+ }),
59
+ now: NOW,
60
+ });
61
+
62
+ expect(findings.length).toBe(1);
63
+ expect(findings[0].code).toBe('public_dns_stale');
64
+ expect(findings[0].subject).toBe('apt.celilo.computer');
65
+ // The finding has to name both addresses; "DNS is wrong" is not actionable.
66
+ expect(findings[0].message).toContain(DEAD_ADDRESS);
67
+ expect(findings[0].message).toContain(FLEET_INGRESS);
68
+ });
69
+
70
+ test('a record that agrees with the fleet is silent', async () => {
71
+ const findings = await auditPublicDns({
72
+ records: [declared('git.celilo.computer')],
73
+ probe: probe({}),
74
+ now: NOW,
75
+ });
76
+ expect(findings).toEqual([]);
77
+ });
78
+
79
+ // ── Hysteresis: propagation is not a fault ────────────────────────────────
80
+ test('divergence inside the record TTL is propagation, not a finding', async () => {
81
+ const assertedSecondsAgo = 60;
82
+ const findings = await auditPublicDns({
83
+ records: [
84
+ declared('git.celilo.computer', new Date(NOW.getTime() - assertedSecondsAgo * 1000)),
85
+ ],
86
+ probe: probe({
87
+ answers: {
88
+ 'git.celilo.computer': { kind: 'answer', ip: DEAD_ADDRESS, ttlSeconds: 180 },
89
+ },
90
+ }),
91
+ now: NOW,
92
+ });
93
+ // An ISP re-lease produces exactly this for up to one TTL. Alerting here
94
+ // would page on every re-lease, and a check nobody trusts is not a check.
95
+ expect(findings).toEqual([]);
96
+ });
97
+
98
+ test('the same divergence once the TTL has elapsed IS a finding', async () => {
99
+ const findings = await auditPublicDns({
100
+ records: [declared('git.celilo.computer', new Date(NOW.getTime() - 181 * 1000))],
101
+ probe: probe({
102
+ answers: {
103
+ 'git.celilo.computer': { kind: 'answer', ip: DEAD_ADDRESS, ttlSeconds: 180 },
104
+ },
105
+ }),
106
+ now: NOW,
107
+ });
108
+ expect(findings.map((f) => f.code)).toEqual(['public_dns_stale']);
109
+ });
110
+
111
+ // ── Missing evidence is louder than absent evidence ───────────────────────
112
+ test('one undetermined run stays quiet', async () => {
113
+ const findings = await auditPublicDns({
114
+ records: [declared('git.celilo.computer')],
115
+ probe: probe({ ingress: { kind: 'undetermined', reason: 'ECONNREFUSED' } }),
116
+ now: NOW,
117
+ });
118
+ expect(findings).toEqual([]);
119
+ });
120
+
121
+ test('consecutive undetermined runs become their own finding', async () => {
122
+ let evidence: PublicDnsEvidence[] = [];
123
+ const run = () =>
124
+ auditPublicDns({
125
+ records: [declared('git.celilo.computer')],
126
+ probe: probe({ ingress: { kind: 'undetermined', reason: 'ECONNREFUSED' } }),
127
+ evidence,
128
+ saveEvidence: (next) => {
129
+ evidence = next;
130
+ },
131
+ now: NOW,
132
+ });
133
+
134
+ expect(await run()).toEqual([]);
135
+ expect(await run()).toEqual([]);
136
+ const findings = await run();
137
+
138
+ expect(findings.map((f) => f.code)).toEqual(['public_dns_unverifiable']);
139
+ // Distinguishable from "the site is unreachable" — this says nothing is
140
+ // currently checking, which is the state the outage hid behind.
141
+ expect(findings[0].message).toContain('unverifiable');
142
+ });
143
+
144
+ test('a run that obtained evidence resets the count', async () => {
145
+ let evidence: PublicDnsEvidence[] = [];
146
+ const save = (next: PublicDnsEvidence[]) => {
147
+ evidence = next;
148
+ };
149
+ const args = (ingress?: IngressObservation) => ({
150
+ records: [declared('git.celilo.computer')],
151
+ probe: probe({ ingress }),
152
+ evidence,
153
+ saveEvidence: save,
154
+ now: NOW,
155
+ });
156
+
157
+ await auditPublicDns(args({ kind: 'undetermined', reason: 'ECONNREFUSED' }));
158
+ await auditPublicDns(args({ kind: 'undetermined', reason: 'ECONNREFUSED' }));
159
+ await auditPublicDns(args());
160
+ expect(evidence).toEqual([]);
161
+
162
+ expect(await auditPublicDns(args({ kind: 'undetermined', reason: 'ECONNREFUSED' }))).toEqual(
163
+ [],
164
+ );
165
+ });
166
+
167
+ // ── Companions: best effort must not mean silent ──────────────────────────
168
+ test('an unclaimed companion is reported with the manual registrar step', async () => {
169
+ const findings = await auditPublicDns({
170
+ records: [{ fqdn: 'www.peterbanka.org', companion: true, lastAssertedAt: LONG_AGO }],
171
+ probe: probe({
172
+ // The parked placeholder Namecheap keeps serving after reporting
173
+ // ErrCount 0 for an update it never applied (design.md D3).
174
+ answers: { 'www.peterbanka.org': { kind: 'answer', ip: '1.2.23.4', ttlSeconds: 1799 } },
175
+ }),
176
+ now: NOW,
177
+ });
178
+
179
+ expect(findings.map((f) => f.code)).toEqual(['public_dns_companion_unclaimed']);
180
+ expect(findings[0].remediation).toContain('Dynamic DNS');
181
+ });
182
+
183
+ test('a name with no public record at all is a finding', async () => {
184
+ const findings = await auditPublicDns({
185
+ records: [declared('nexus.lunacycle.net')],
186
+ probe: probe({ fallback: { kind: 'no_record' } }),
187
+ now: NOW,
188
+ });
189
+ expect(findings.map((f) => f.code)).toEqual(['public_dns_missing']);
190
+ });
191
+
192
+ test('an empty ledger never touches the network', async () => {
193
+ const findings = await auditPublicDns({
194
+ records: [],
195
+ probe: {
196
+ resolver: 'x',
197
+ echoService: 'y',
198
+ observeIngress() {
199
+ throw new Error('probed with nothing to check');
200
+ },
201
+ resolve() {
202
+ throw new Error('probed with nothing to check');
203
+ },
204
+ },
205
+ now: NOW,
206
+ });
207
+ expect(findings).toEqual([]);
208
+ });
209
+ });