@celilo/cli 0.14.2 → 0.14.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^0.9.0",
60
+ "@celilo/capabilities": "^0.9.1",
61
61
  "@celilo/cli-display": "^0.1.9",
62
62
  "@celilo/core": "^0.1.0",
63
63
  "@celilo/event-bus": "^0.1.8",
@@ -516,7 +516,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
516
516
  };
517
517
  }
518
518
 
519
- test('registers the public A record at externalIp for a NEW hostname', async () => {
519
+ test('registers the public A record for a NEW hostname without supplying an IP', async () => {
520
520
  const { ops } = makeRouteOps();
521
521
  const { registrar, calls } = makeRegistrar();
522
522
  const cap = createPublicWeb({
@@ -530,7 +530,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
530
530
  dnsManagedDomains: ['example.com'],
531
531
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
532
532
  dnsRegistrar: registrar as any,
533
- externalIp: '100.100.0.100',
534
533
  });
535
534
 
536
535
  await cap.register_route({
@@ -539,7 +538,9 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
539
538
  hostname: 'nexus.example.com',
540
539
  });
541
540
 
542
- expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: '100.100.0.100' }]);
541
+ // No `ip` the registrar publishes the source address of the update
542
+ // request, which is by construction the address the internet must dial.
543
+ expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]);
543
544
  });
544
545
 
545
546
  test('skips the default hostname (caddy already registered it at install)', async () => {
@@ -556,7 +557,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
556
557
  dnsManagedDomains: ['example.com'],
557
558
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
558
559
  dnsRegistrar: registrar as any,
559
- externalIp: '100.100.0.100',
560
560
  });
561
561
 
562
562
  await cap.register_route({ type: 'static', path: '/', hostname: 'www.example.com' });
@@ -568,7 +568,13 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
568
568
  // hostname that can't be wired FAILS the deploy instead of silently
569
569
  // reporting success (the "served but unreachable" anti-pattern).
570
570
 
571
- test('fails loudly when externalIp is unknown for a NEW hostname', async () => {
571
+ // The regression guard for #464. This case used to THROW "caddy has no known
572
+ // external IP" — and it threw on every real fleet, because the stored copy it
573
+ // demanded was never populated: caddy produced a `public_ip` hook output that
574
+ // its manifest never declared, so the framework discarded it. A correct fleet
575
+ // with correct public DNS could not register a new hostname. Registration must
576
+ // not depend on knowing the external IP at all.
577
+ test('registers a NEW hostname even though no external IP is known anywhere', async () => {
572
578
  const { ops } = makeRouteOps();
573
579
  const { registrar, calls } = makeRegistrar();
574
580
  const cap = createPublicWeb({
@@ -582,13 +588,11 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
582
588
  dnsManagedDomains: ['example.com'],
583
589
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
584
590
  dnsRegistrar: registrar as any,
585
- // externalIp omitted
586
591
  });
587
592
 
588
- await expect(
589
- cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
590
- ).rejects.toThrow('no known external IP');
591
- expect(calls).toHaveLength(0);
593
+ await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' });
594
+
595
+ expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]);
592
596
  });
593
597
 
594
598
  test('fails loudly when no dns_registrar is available for a NEW hostname', async () => {
@@ -602,8 +606,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
602
606
  hostnames: ['www.example.com'],
603
607
  caddyModuleId: 'caddy',
604
608
  dnsManagedDomains: ['example.com'],
605
- // dnsRegistrar + externalIp both omitted
606
- externalIp: '100.100.0.100',
609
+ // dnsRegistrar omitted
607
610
  });
608
611
 
609
612
  await expect(
@@ -629,7 +632,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
629
632
  dnsManagedDomains: ['example.com'],
630
633
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
631
634
  dnsRegistrar: registrar as any,
632
- externalIp: '100.100.0.100',
633
635
  });
634
636
 
635
637
  await expect(
@@ -656,7 +658,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
656
658
  dnsManagedDomains: ['example.com'],
657
659
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
658
660
  dnsRegistrar: registrar as any,
659
- externalIp: '100.100.0.100',
660
661
  // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
661
662
  dnsInternal: dnsInternal as any,
662
663
  });
@@ -687,7 +688,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
687
688
  dnsManagedDomains: ['example.com'],
688
689
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
689
690
  dnsRegistrar: registrar as any,
690
- externalIp: '100.100.0.100',
691
691
  firewallNatIp: '192.168.0.253',
692
692
  // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
693
693
  dnsInternal: dnsInternal as any,
@@ -52,7 +52,7 @@ import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
52
52
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
53
53
  import { collectFirewallReach } from '../../services/firewall-reach';
54
54
  import { runAllHealthChecks } from '../../services/health-runner';
55
- import { listMachines } from '../../services/machine-pool';
55
+ import { probeMachines } from '../../services/machine-probe';
56
56
  import { parseStoredConfigValue } from '../../services/module-config';
57
57
  import { buildTerraformEnvForModule } from '../../services/terraform-env';
58
58
  import { hasFlag } from '../parser';
@@ -353,43 +353,11 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
353
353
  }),
354
354
  );
355
355
 
356
- // Machines-reachable: SSH probe each pool machine in parallel.
357
- // BatchMode=yes prevents password prompt hangs; ConnectTimeout=5
358
- // bounds wait on unresponsive hosts. The audit's collapse logic
359
- // turns "all unreachable" into a single host/network finding.
360
- const allMachines = await listMachines();
361
- const machineReachableResults: MachineReachableResult[] = await Promise.all(
362
- allMachines.map(async (m): Promise<MachineReachableResult> => {
363
- try {
364
- await execFileAsync(
365
- 'ssh',
366
- [
367
- '-o',
368
- 'BatchMode=yes',
369
- '-o',
370
- 'ConnectTimeout=5',
371
- '-o',
372
- 'StrictHostKeyChecking=no',
373
- '-o',
374
- 'UserKnownHostsFile=/dev/null',
375
- `${m.sshUser}@${m.ipAddress}`,
376
- 'true',
377
- ],
378
- { timeout: 8000 },
379
- );
380
- return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
381
- } catch (err) {
382
- const e = err as { stderr?: string; message?: string };
383
- return {
384
- id: m.id,
385
- hostname: m.hostname,
386
- ipAddress: m.ipAddress,
387
- reachable: false,
388
- message: (e.stderr || e.message || 'unknown error').slice(0, 200),
389
- };
390
- }
391
- }),
392
- );
356
+ // Machines-reachable: probe each pool machine. Shared with the monitor sweep
357
+ // (services/machine-probe.ts) this block used to be a second copy of the
358
+ // same SSH logic, and both copies reported the local management box as
359
+ // unreachable because celilo does not hold an SSH key for itself.
360
+ const machineReachableResults: MachineReachableResult[] = await probeMachines();
393
361
 
394
362
  const migrationsFolder = findMigrationsFolderSafe();
395
363
 
@@ -361,17 +361,6 @@ export async function loadCapabilityFunctions(
361
361
  debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
362
362
  }
363
363
 
364
- // Caddy's external (WAN) IP for the D1 public A record (M1 / #328). Caddy
365
- // learns it from its own firewall.exposeService at install and persists it
366
- // as the `public_ip` hook output (stored as a caddy secret). register_route
367
- // points a new hostname's PUBLIC record here, so it's reachable from one
368
- // deploy — no per-hostname DNAT (shared :443 ingress).
369
- const caddyExternalIp =
370
- typeof providerSecrets.public_ip === 'string' ? providerSecrets.public_ip : undefined;
371
- if (caddyExternalIp) {
372
- debugLog(`public_web: using caddy externalIp ${caddyExternalIp} for public DNS`);
373
- }
374
-
375
364
  // Caddy's configured hostnames — public_web rejects routes for any
376
365
  // hostname not in this list, throwing a structured error that runs
377
366
  // caddy's `managed_hostname` ensure interview. Empty list means
@@ -484,7 +473,6 @@ export async function loadCapabilityFunctions(
484
473
  dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
485
474
  firewallNatIp,
486
475
  dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined,
487
- externalIp: caddyExternalIp,
488
476
  hostnames: caddyHostnames,
489
477
  caddyModuleId: provider.moduleId,
490
478
  dnsManagedDomains,
@@ -12,14 +12,9 @@
12
12
  * as "nothing is wrong".
13
13
  */
14
14
 
15
- import { execFile } from 'node:child_process';
16
- import { promisify } from 'node:util';
17
15
  import { auditMachinesReachable } from '../audit/machines-reachable';
18
- import type { MachineReachableResult } from '../audit/machines-reachable';
19
16
  import type { DriftCategory, DriftFinding } from '../audit/types';
20
- import { listMachines } from '../machine-pool';
21
-
22
- const execFileAsync = promisify(execFile);
17
+ import { probeMachines } from '../machine-probe';
23
18
 
24
19
  /** Categories a monitor can currently schedule. */
25
20
  export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = ['machines_reachable'];
@@ -28,49 +23,6 @@ export function isSchedulableBuiltin(category: string): category is DriftCategor
28
23
  return (SCHEDULABLE_BUILTIN_CHECKS as readonly string[]).includes(category);
29
24
  }
30
25
 
31
- /**
32
- * SSH-probe every pool machine.
33
- *
34
- * `BatchMode=yes` prevents a password prompt from hanging the probe forever,
35
- * and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
36
- * condition this check exists to detect must not be the one that wedges it.
37
- */
38
- async function probeMachines(): Promise<MachineReachableResult[]> {
39
- const machines = await listMachines();
40
- return Promise.all(
41
- machines.map(async (m): Promise<MachineReachableResult> => {
42
- try {
43
- await execFileAsync(
44
- 'ssh',
45
- [
46
- '-o',
47
- 'BatchMode=yes',
48
- '-o',
49
- 'ConnectTimeout=5',
50
- '-o',
51
- 'StrictHostKeyChecking=no',
52
- '-o',
53
- 'UserKnownHostsFile=/dev/null',
54
- `${m.sshUser}@${m.ipAddress}`,
55
- 'true',
56
- ],
57
- { timeout: 8000 },
58
- );
59
- return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
60
- } catch (err) {
61
- const e = err as { stderr?: string; message?: string };
62
- return {
63
- id: m.id,
64
- hostname: m.hostname,
65
- ipAddress: m.ipAddress,
66
- reachable: false,
67
- message: e.stderr?.trim() || e.message || 'SSH probe failed',
68
- };
69
- }
70
- }),
71
- );
72
- }
73
-
74
26
  export async function runBuiltinCheckForMonitor(category: DriftCategory): Promise<DriftFinding[]> {
75
27
  if (category === 'machines_reachable') {
76
28
  return auditMachinesReachable({ results: await probeMachines() });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The local management box must never be reported unreachable.
3
+ *
4
+ * celilo runs there as the `celilo` user and deliberately holds no SSH key for
5
+ * itself, so probing `root@127.0.0.1` fails with `Permission denied (publickey)`
6
+ * on a completely healthy host. That produced a permanently firing
7
+ * `machines_reachable` alert against celilo-mgr — and that monitor is
8
+ * unsuppressible by design, so nothing could explain it away.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { auditMachinesReachable } from './audit/machines-reachable';
13
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
14
+
15
+ describe('local machine reachability', () => {
16
+ test('LOCAL_MACHINE_IP is the loopback address the machine pool records', () => {
17
+ // The probe's skip is keyed on this exact value, so pin it.
18
+ expect(LOCAL_MACHINE_IP).toBe('127.0.0.1');
19
+ });
20
+
21
+ test('a reachable local box produces no finding', async () => {
22
+ const findings = await auditMachinesReachable({
23
+ results: [
24
+ { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
25
+ { id: 'briq', hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
26
+ ],
27
+ });
28
+
29
+ expect(findings).toEqual([]);
30
+ });
31
+
32
+ test('an unreachable remote machine still produces a finding', async () => {
33
+ // The skip must not blunt the check for the machines it exists to watch.
34
+ const findings = await auditMachinesReachable({
35
+ results: [
36
+ { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
37
+ {
38
+ id: 'briq',
39
+ hostname: 'briq',
40
+ ipAddress: '192.168.0.254',
41
+ reachable: false,
42
+ message: 'connection refused',
43
+ },
44
+ ],
45
+ });
46
+
47
+ expect(findings).toHaveLength(1);
48
+ expect(findings[0].message).toContain('briq');
49
+ });
50
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reachability probe for the machine pool.
3
+ *
4
+ * One implementation, deliberately. This SSH probe previously existed twice —
5
+ * once in `alerting/builtin-source.ts` for the monitor sweep and once in
6
+ * `cli/commands/system-audit.ts` for `celilo system audit` — as identical
7
+ * copy-pasted blocks. Both carried the same bug, and fixing one would have left
8
+ * the other reporting the management server as unreachable forever.
9
+ */
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ import type { MachineReachableResult } from './audit/machines-reachable';
14
+ import { listMachines } from './machine-pool';
15
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Probe every machine in the pool.
21
+ *
22
+ * `BatchMode=yes` prevents a password prompt from hanging the probe forever,
23
+ * and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
24
+ * condition this check exists to detect must not be the one that wedges it.
25
+ *
26
+ * The local management box is reported reachable WITHOUT probing it. celilo
27
+ * runs there as the `celilo` user and deliberately does not materialize an SSH
28
+ * key for itself, so `ssh root@127.0.0.1` fails with `Permission denied
29
+ * (publickey)` on a perfectly healthy host. Left unhandled that produced a
30
+ * permanently firing `machines_reachable` alert against celilo-mgr — and since
31
+ * that monitor is unsuppressible by design, nothing could explain it away.
32
+ *
33
+ * The check is also meaningless there: if the box running this code were
34
+ * unreachable, this code would not be running.
35
+ */
36
+ export async function probeMachines(): Promise<MachineReachableResult[]> {
37
+ const machines = await listMachines();
38
+ return Promise.all(
39
+ machines.map(async (m): Promise<MachineReachableResult> => {
40
+ if (m.ipAddress === LOCAL_MACHINE_IP) {
41
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
42
+ }
43
+ try {
44
+ await execFileAsync(
45
+ 'ssh',
46
+ [
47
+ '-o',
48
+ 'BatchMode=yes',
49
+ '-o',
50
+ 'ConnectTimeout=5',
51
+ '-o',
52
+ 'StrictHostKeyChecking=no',
53
+ '-o',
54
+ 'UserKnownHostsFile=/dev/null',
55
+ `${m.sshUser}@${m.ipAddress}`,
56
+ 'true',
57
+ ],
58
+ { timeout: 8000 },
59
+ );
60
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
61
+ } catch (err) {
62
+ const e = err as { stderr?: string; message?: string };
63
+ return {
64
+ id: m.id,
65
+ hostname: m.hostname,
66
+ ipAddress: m.ipAddress,
67
+ reachable: false,
68
+ message: e.stderr?.trim() || e.message || 'SSH probe failed',
69
+ };
70
+ }
71
+ }),
72
+ );
73
+ }