@celilo/cli 0.8.2 → 0.9.1

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 (46) hide show
  1. package/AGENTS.md +10 -18
  2. package/CELILO_CORE_MODULES.md +61 -0
  3. package/CELILO_SUBSYSTEMS.md +83 -0
  4. package/README.md +1539 -48
  5. package/drizzle/0012_module_systems_sizing.sql +3 -0
  6. package/drizzle/0013_dns_view_overrides.sql +1 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +5 -10
  9. package/src/capabilities/well-known.test.ts +12 -66
  10. package/src/capabilities/well-known.ts +11 -12
  11. package/src/cli/command-registry.ts +65 -1
  12. package/src/cli/commands/module-upgrade.test.ts +29 -0
  13. package/src/cli/commands/module-upgrade.ts +57 -24
  14. package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
  15. package/src/cli/commands/proxmox-instance-list.ts +140 -0
  16. package/src/cli/commands/proxmox-instance-resize.ts +235 -0
  17. package/src/cli/commands/proxmox-node-list.ts +1 -34
  18. package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
  19. package/src/cli/commands/proxmox-resize-guards.ts +102 -0
  20. package/src/cli/commands/proxmox-service.ts +38 -0
  21. package/src/cli/completion.ts +11 -37
  22. package/src/cli/index.ts +15 -0
  23. package/src/cli/validators.test.ts +1 -206
  24. package/src/cli/validators.ts +0 -168
  25. package/src/db/schema.ts +21 -1
  26. package/src/hooks/capability-loader.ts +22 -0
  27. package/src/manifest/template-validator.test.ts +31 -1
  28. package/src/manifest/template-validator.ts +9 -0
  29. package/src/services/aspect-approvals.test.ts +52 -0
  30. package/src/services/aspect-approvals.ts +41 -8
  31. package/src/services/deployed-systems.test.ts +73 -1
  32. package/src/services/deployed-systems.ts +72 -0
  33. package/src/services/dns-internal-records.test.ts +76 -3
  34. package/src/services/dns-internal-records.ts +52 -3
  35. package/src/services/dns-provider-backfill.ts +15 -3
  36. package/src/services/fleet-checks.test.ts +18 -16
  37. package/src/services/machine-detector.ts +34 -12
  38. package/src/services/programmatic-responder.aspect.test.ts +157 -0
  39. package/src/services/programmatic-responder.ts +51 -0
  40. package/src/templates/generator.ts +49 -1
  41. package/src/utils/shell.test.ts +1 -163
  42. package/src/utils/shell.ts +0 -100
  43. package/src/validation/schemas.ts +0 -5
  44. package/src/variables/context.ts +36 -7
  45. package/CLI_USAGE.md +0 -433
  46. package/src/config/env.ts +0 -41
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { rm } from 'node:fs/promises';
4
+ import { and, eq } from 'drizzle-orm';
4
5
  import { type DbClient, createDbClient } from '../db/client';
5
6
  import {
6
7
  containerServices,
@@ -10,7 +11,7 @@ import {
10
11
  moduleSystems,
11
12
  modules,
12
13
  } from '../db/schema';
13
- import { backfillModuleSystems, getModuleSystems } from './deployed-systems';
14
+ import { backfillModuleSystems, getModuleSystems, upsertDeployedSystem } from './deployed-systems';
14
15
 
15
16
  const TEST_DB_PATH = './test-deployed-systems.db';
16
17
 
@@ -233,3 +234,74 @@ describe('backfillModuleSystems', () => {
233
234
  expect(getModuleSystems('namecheap', db)).toHaveLength(0);
234
235
  });
235
236
  });
237
+
238
+ /**
239
+ * Canonical instance sizing (ISS-0150): sizing is seeded onto module_systems
240
+ * once at first provision and then OWNED by `celilo proxmox … resize` — a routine
241
+ * re-deploy must never reset a resized instance back to its manifest minimum.
242
+ */
243
+ describe('upsertDeployedSystem sizing — seed-once (ISS-0150)', () => {
244
+ let db: DbClient;
245
+
246
+ beforeEach(() => {
247
+ db = createDbClient({ path: TEST_DB_PATH });
248
+ db.insert(modules)
249
+ .values({
250
+ id: 'm1',
251
+ name: 'm1',
252
+ version: '1.0.0',
253
+ manifestData: {},
254
+ sourcePath: '/tmp/m1',
255
+ state: 'VERIFIED',
256
+ })
257
+ .run();
258
+ });
259
+
260
+ afterEach(async () => {
261
+ db.$client.close();
262
+ for (const suffix of ['', '-shm', '-wal']) {
263
+ const p = `${TEST_DB_PATH}${suffix}`;
264
+ if (existsSync(p)) await rm(p);
265
+ }
266
+ });
267
+
268
+ const sizeArgs = (memory: number) => ({
269
+ name: 'main',
270
+ hostname: 'h',
271
+ ipv4Address: '10.0.0.5/24',
272
+ zone: 'app' as const,
273
+ infraType: 'container_service' as const,
274
+ vmid: 200,
275
+ cpu: 4,
276
+ memory,
277
+ disk: 80,
278
+ });
279
+
280
+ const row = () =>
281
+ db
282
+ .select()
283
+ .from(moduleSystems)
284
+ .where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
285
+ .get();
286
+
287
+ test('seeds sizing on first insert', () => {
288
+ upsertDeployedSystem(db, 'm1', sizeArgs(8192));
289
+ const r = row();
290
+ expect(r?.cpu).toBe(4);
291
+ expect(r?.memory).toBe(8192);
292
+ expect(r?.disk).toBe(80);
293
+ });
294
+
295
+ test('a re-deploy does NOT reset a resized instance to the manifest minimum', () => {
296
+ upsertDeployedSystem(db, 'm1', sizeArgs(8192)); // first provision: seed 8 GB
297
+ // Simulate `celilo proxmox vm resize` bumping the canonical size to 16 GB.
298
+ db.update(moduleSystems)
299
+ .set({ memory: 16384 })
300
+ .where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
301
+ .run();
302
+ // Re-deploy passes the manifest minimum (8 GB) again — must be ignored.
303
+ upsertDeployedSystem(db, 'm1', sizeArgs(8192));
304
+ expect(row()?.memory).toBe(16384);
305
+ expect(row()?.cpu).toBe(4);
306
+ });
307
+ });
@@ -52,6 +52,52 @@ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem
52
52
  return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
53
53
  }
54
54
 
55
+ /**
56
+ * A celilo-provisioned instance with its canonical size (ISS-0150). CLI-internal
57
+ * shape (NOT the `DeployedSystem` capability type) for the `celilo proxmox
58
+ * vm/ct …` surface, which needs the sizing columns the capability type omits.
59
+ */
60
+ export interface ProvisionedSystem {
61
+ moduleId: string;
62
+ name: string;
63
+ hostname: string;
64
+ ipv4Address: string;
65
+ zone: NetworkZone;
66
+ serviceId: string | null;
67
+ vmid: number | null;
68
+ /** Canonical desired size (null until seeded / for non-Proxmox). */
69
+ cpu: number | null;
70
+ memory: number | null;
71
+ disk: number | null;
72
+ }
73
+
74
+ /**
75
+ * Every celilo-provisioned (container_service) system with a Proxmox vmid, across
76
+ * all modules, including its canonical sizing — the read model behind
77
+ * `celilo proxmox vm/ct list|show`. Ordered by vmid for stable output.
78
+ */
79
+ export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] {
80
+ return db
81
+ .select()
82
+ .from(moduleSystems)
83
+ .where(eq(moduleSystems.infraType, 'container_service'))
84
+ .all()
85
+ .filter((r) => r.vmid != null)
86
+ .map((r) => ({
87
+ moduleId: r.moduleId,
88
+ name: r.name,
89
+ hostname: r.hostname,
90
+ ipv4Address: r.ipv4Address,
91
+ zone: r.zone,
92
+ serviceId: r.serviceId,
93
+ vmid: r.vmid,
94
+ cpu: r.cpu,
95
+ memory: r.memory,
96
+ disk: r.disk,
97
+ }))
98
+ .sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0));
99
+ }
100
+
55
101
  /**
56
102
  * All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
57
103
  * `zones`, across every module — the LXC complement to machine-pool's
@@ -93,6 +139,15 @@ export interface DeployedSystemInput {
93
139
  machineId?: string | null;
94
140
  serviceId?: string | null;
95
141
  vmid?: number | null;
142
+ /**
143
+ * Canonical deployed size (ISS-0150), seeded from the module's
144
+ * `requires.system` at first provision. Seed-once: written on INSERT only and
145
+ * preserved across re-deploys (omitted from the conflict update), so a later
146
+ * `celilo proxmox … resize` is not reset back to the manifest minimum.
147
+ */
148
+ cpu?: number | null;
149
+ memory?: number | null;
150
+ disk?: number | null;
96
151
  }
97
152
 
98
153
  /**
@@ -117,6 +172,11 @@ export function upsertDeployedSystem(
117
172
  machineId: system.machineId ?? null,
118
173
  serviceId: system.serviceId ?? null,
119
174
  vmid: system.vmid ?? null,
175
+ // Seed-once (ISS-0150): set on insert; deliberately omitted from the
176
+ // conflict update below so a resize survives re-deploys.
177
+ cpu: system.cpu ?? null,
178
+ memory: system.memory ?? null,
179
+ disk: system.disk ?? null,
120
180
  updatedAt: new Date(),
121
181
  })
122
182
  .onConflictDoUpdate({
@@ -129,6 +189,9 @@ export function upsertDeployedSystem(
129
189
  machineId: system.machineId ?? null,
130
190
  serviceId: system.serviceId ?? null,
131
191
  vmid: system.vmid ?? null,
192
+ // NOTE: cpu/memory/disk intentionally NOT updated here — sizing is
193
+ // canonical state owned by `celilo proxmox … resize`, not reset by a
194
+ // routine re-deploy (seed-once). See ISS-0150 / CLAUDE.md.
132
195
  updatedAt: new Date(),
133
196
  },
134
197
  })
@@ -207,6 +270,11 @@ export async function recordDeployedSystemForModule(
207
270
  machineId: infrastructure?.machineId ?? null,
208
271
  serviceId: infrastructure?.serviceId ?? null,
209
272
  vmid: Number.isNaN(vmid as number) ? null : vmid,
273
+ // Seed canonical size from requires.system (seed-once; preserved across
274
+ // re-deploys). Only meaningful for celilo-provisioned instances.
275
+ cpu: decl.resources.cpu ?? null,
276
+ memory: decl.resources.memory ?? null,
277
+ disk: decl.resources.disk ?? null,
210
278
  });
211
279
 
212
280
  return getModuleSystems(moduleId, db);
@@ -279,6 +347,10 @@ export function backfillModuleSystems(db: DbClient): string[] {
279
347
  machineId: infra.machineId ?? null,
280
348
  serviceId: infra.serviceId ?? null,
281
349
  vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null,
350
+ // Seed canonical size from requires.system for upgraded deployments.
351
+ cpu: decl.resources.cpu ?? null,
352
+ memory: decl.resources.memory ?? null,
353
+ disk: decl.resources.disk ?? null,
282
354
  });
283
355
  backfilled.push(infra.moduleId);
284
356
  }
@@ -3,11 +3,13 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DnsRecordRequest } from '@celilo/capabilities';
6
+ import type { ViewOverride } from '@celilo/capabilities';
6
7
  import type { DbClient } from '../db/client';
7
8
  import { modules } from '../db/schema';
8
9
  import { setupTestDatabase } from '../test-utils/setup-test-db';
9
10
  import {
10
11
  listDnsInternalRecords,
12
+ listViewOverrides,
11
13
  recordDnsInternalRecord,
12
14
  removeDnsInternalRecord,
13
15
  withDnsInternalLedger,
@@ -70,10 +72,27 @@ describe('dns-internal-records ledger', () => {
70
72
  expect(rows.map((r) => r.host)).toEqual(['b.x']);
71
73
  });
72
74
 
75
+ it('listViewOverrides returns only fronted records (zone IP set), as host→zoneIp', () => {
76
+ recordDnsInternalRecord(db, { ...ctx(), host: 'plain.x', ip: '192.168.0.253' });
77
+ recordDnsInternalRecord(db, {
78
+ ...ctx(),
79
+ host: 'git.celilo.computer',
80
+ ip: '192.168.0.253',
81
+ zoneRoutableIp: '10.0.10.10',
82
+ });
83
+ const overrides = listViewOverrides(db, 'technitium');
84
+ expect(overrides).toEqual([{ host: 'git.celilo.computer', ip: '10.0.10.10' }]);
85
+ });
86
+
73
87
  describe('withDnsInternalLedger', () => {
74
- function fakeProvider() {
88
+ function fakeProvider(withViews = false) {
75
89
  const calls: Array<['register' | 'delete', DnsRecordRequest]> = [];
76
- const iface = {
90
+ const reconcileCalls: ViewOverride[][] = [];
91
+ const iface: {
92
+ registerRecord(req: DnsRecordRequest): Promise<void>;
93
+ deleteRecord(req: DnsRecordRequest): Promise<void>;
94
+ reconcileViews?(o: ViewOverride[]): Promise<void>;
95
+ } = {
77
96
  async registerRecord(req: DnsRecordRequest) {
78
97
  calls.push(['register', req]);
79
98
  },
@@ -81,7 +100,12 @@ describe('dns-internal-records ledger', () => {
81
100
  calls.push(['delete', req]);
82
101
  },
83
102
  };
84
- return { iface, calls };
103
+ if (withViews) {
104
+ iface.reconcileViews = async (o: ViewOverride[]) => {
105
+ reconcileCalls.push(o);
106
+ };
107
+ }
108
+ return { iface, calls, reconcileCalls };
85
109
  }
86
110
 
87
111
  it('records A-record registrations and passes the call through', async () => {
@@ -109,6 +133,55 @@ describe('dns-internal-records ledger', () => {
109
133
  expect(listDnsInternalRecords(db)).toHaveLength(0);
110
134
  });
111
135
 
136
+ it('reconciles views from the full ledger set when a fronted record is registered', async () => {
137
+ const { iface, reconcileCalls } = fakeProvider(true);
138
+ const wrapped = withDnsInternalLedger(iface, ctx());
139
+ // A plain (non-fronted) record must NOT trigger a view reconcile.
140
+ await wrapped.registerRecord({ host: 'plain.x', type: 'A', value: '192.168.0.253' });
141
+ expect(reconcileCalls).toHaveLength(0);
142
+ // A fronted record (zoneRoutableValue set) triggers a reconcile from the
143
+ // COMPLETE fronted set in the ledger.
144
+ await wrapped.registerRecord({
145
+ host: 'git.celilo.computer',
146
+ type: 'A',
147
+ value: '192.168.0.253',
148
+ zoneRoutableValue: '10.0.10.10',
149
+ });
150
+ expect(reconcileCalls).toHaveLength(1);
151
+ expect(reconcileCalls[0]).toEqual([{ host: 'git.celilo.computer', ip: '10.0.10.10' }]);
152
+ });
153
+
154
+ it('reconciles views on delete (a removed host drops from the set)', async () => {
155
+ const { iface, reconcileCalls } = fakeProvider(true);
156
+ const wrapped = withDnsInternalLedger(iface, ctx());
157
+ await wrapped.registerRecord({
158
+ host: 'git.celilo.computer',
159
+ type: 'A',
160
+ value: '192.168.0.253',
161
+ zoneRoutableValue: '10.0.10.10',
162
+ });
163
+ await wrapped.deleteRecord({
164
+ host: 'git.celilo.computer',
165
+ type: 'A',
166
+ value: '192.168.0.253',
167
+ });
168
+ // Last reconcile reflects the now-empty fronted set.
169
+ expect(reconcileCalls.at(-1)).toEqual([]);
170
+ });
171
+
172
+ it('is a no-op reconcile path for providers without view support', async () => {
173
+ const { iface } = fakeProvider(false);
174
+ const wrapped = withDnsInternalLedger(iface, ctx());
175
+ // Must not throw despite a fronted registration when reconcileViews is absent.
176
+ await wrapped.registerRecord({
177
+ host: 'git.celilo.computer',
178
+ type: 'A',
179
+ value: '192.168.0.253',
180
+ zoneRoutableValue: '10.0.10.10',
181
+ });
182
+ expect(listViewOverrides(db, 'technitium')).toHaveLength(1);
183
+ });
184
+
112
185
  it('does not write the ledger if the underlying register throws', async () => {
113
186
  const iface = {
114
187
  async registerRecord(): Promise<void> {
@@ -13,14 +13,16 @@
13
13
  * Row lifecycle is FK cascade — records die with their provider or consumer.
14
14
  */
15
15
 
16
- import type { DnsInternalCapability, DnsRecordRequest } from '@celilo/capabilities';
17
- import { and, eq } from 'drizzle-orm';
16
+ import type { DnsInternalCapability, DnsRecordRequest, ViewOverride } from '@celilo/capabilities';
17
+ import { and, eq, isNotNull } from 'drizzle-orm';
18
18
  import type { DbClient } from '../db/client';
19
19
  import { dnsInternalRecords } from '../db/schema';
20
20
 
21
21
  export interface DnsInternalRecordRow {
22
22
  host: string;
23
23
  ip: string;
24
+ /** In-zone split-horizon answer (caddy's zone IP), or null for plain records. */
25
+ zoneRoutableIp: string | null;
24
26
  providerModuleId: string;
25
27
  consumerModuleId: string;
26
28
  registeredAt: Date;
@@ -34,6 +36,7 @@ export function listDnsInternalRecords(
34
36
  .select({
35
37
  host: dnsInternalRecords.host,
36
38
  ip: dnsInternalRecords.ip,
39
+ zoneRoutableIp: dnsInternalRecords.zoneRoutableIp,
37
40
  providerModuleId: dnsInternalRecords.providerModuleId,
38
41
  consumerModuleId: dnsInternalRecords.consumerModuleId,
39
42
  registeredAt: dnsInternalRecords.registeredAt,
@@ -44,16 +47,45 @@ export function listDnsInternalRecords(
44
47
  : query.all();
45
48
  }
46
49
 
50
+ /**
51
+ * The COMPLETE set of source-based split-horizon view overrides a provider
52
+ * should serve — every ledger record that carries a zone-routable IP (ISS-0156).
53
+ * This is the desired state the resolver's view config is reconciled from; the
54
+ * provider's `reconcileViews` is the single writer that materializes it.
55
+ */
56
+ export function listViewOverrides(db: DbClient, providerModuleId: string): ViewOverride[] {
57
+ return db
58
+ .select({ host: dnsInternalRecords.host, ip: dnsInternalRecords.zoneRoutableIp })
59
+ .from(dnsInternalRecords)
60
+ .where(
61
+ and(
62
+ eq(dnsInternalRecords.providerModuleId, providerModuleId),
63
+ isNotNull(dnsInternalRecords.zoneRoutableIp),
64
+ ),
65
+ )
66
+ .all()
67
+ .map((r) => ({ host: r.host, ip: r.ip as string }));
68
+ }
69
+
47
70
  export function recordDnsInternalRecord(
48
71
  db: DbClient,
49
- record: { providerModuleId: string; consumerModuleId: string; host: string; ip: string },
72
+ record: {
73
+ providerModuleId: string;
74
+ consumerModuleId: string;
75
+ host: string;
76
+ ip: string;
77
+ /** In-zone split-horizon answer (caddy's zone IP); null/absent for plain records. */
78
+ zoneRoutableIp?: string | null;
79
+ },
50
80
  ): void {
81
+ const zoneRoutableIp = record.zoneRoutableIp ?? null;
51
82
  db.insert(dnsInternalRecords)
52
83
  .values({
53
84
  providerModuleId: record.providerModuleId,
54
85
  consumerModuleId: record.consumerModuleId,
55
86
  host: record.host,
56
87
  ip: record.ip,
88
+ zoneRoutableIp,
57
89
  registeredAt: new Date(),
58
90
  })
59
91
  .onConflictDoUpdate({
@@ -61,6 +93,7 @@ export function recordDnsInternalRecord(
61
93
  set: {
62
94
  consumerModuleId: record.consumerModuleId,
63
95
  ip: record.ip,
96
+ zoneRoutableIp,
64
97
  registeredAt: new Date(),
65
98
  },
66
99
  })
@@ -93,6 +126,17 @@ export function withDnsInternalLedger(
93
126
  ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
94
127
  ): DnsInternalCapability {
95
128
  const isA = (request: DnsRecordRequest) => request.type.toUpperCase() === 'A';
129
+
130
+ // Source-based split-horizon (ISS-0156): after the ledger changes, re-materialize
131
+ // the provider's view config from the COMPLETE desired set. The provider's
132
+ // reconcileViews is the single writer; driving it from the ledger here (which
133
+ // both the live public_web path and the deploy-time backfill flow through)
134
+ // keeps live and reconcile in agreement. No-op for providers without views.
135
+ const reconcileViews = async (): Promise<void> => {
136
+ if (typeof iface.reconcileViews !== 'function') return;
137
+ await iface.reconcileViews(listViewOverrides(ctx.db, ctx.providerModuleId));
138
+ };
139
+
96
140
  return {
97
141
  ...iface,
98
142
  async registerRecord(request: DnsRecordRequest): Promise<void> {
@@ -103,7 +147,10 @@ export function withDnsInternalLedger(
103
147
  consumerModuleId: ctx.consumerModuleId,
104
148
  host: request.host,
105
149
  ip: request.value,
150
+ zoneRoutableIp: request.zoneRoutableValue ?? null,
106
151
  });
152
+ // Only a fronted record (one carrying a zone-routable IP) changes views.
153
+ if (request.zoneRoutableValue) await reconcileViews();
107
154
  }
108
155
  },
109
156
  async deleteRecord(request: DnsRecordRequest): Promise<void> {
@@ -113,6 +160,8 @@ export function withDnsInternalLedger(
113
160
  providerModuleId: ctx.providerModuleId,
114
161
  host: request.host,
115
162
  });
163
+ // The removed host may have been a fronted override — recompute the set.
164
+ await reconcileViews();
116
165
  }
117
166
  },
118
167
  };
@@ -18,7 +18,11 @@ import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
18
18
  import { and, eq, inArray, or } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
20
  import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
21
- import { loadCapabilityFunctions, resolveFirewallNatIp } from '../hooks/capability-loader';
21
+ import {
22
+ loadCapabilityFunctions,
23
+ resolveCaddyZoneIp,
24
+ resolveFirewallNatIp,
25
+ } from '../hooks/capability-loader';
22
26
  import { runNamedHook } from '../hooks/run-named-hook';
23
27
  import type { HookName } from '../hooks/types';
24
28
  import { getModuleSystems } from './deployed-systems';
@@ -137,13 +141,21 @@ export async function backfillWebRouteDns(
137
141
  return;
138
142
  }
139
143
 
144
+ // caddy's zone-routable IP — the in-zone split-horizon answer (ISS-0156). When
145
+ // it differs from the natIp, each fronted hostname carries it as
146
+ // `zoneRoutableValue`; the provider's reconcileViews (driven from the ledger by
147
+ // the registration wrapper) materializes the per-zone view overrides.
148
+ const caddyZoneIp = await resolveCaddyZoneIp(db);
149
+ const zoneRoutableValue = caddyZoneIp && caddyZoneIp !== natIp ? caddyZoneIp : undefined;
150
+ const viewNote = zoneRoutableValue ? ` (in-zone view → ${zoneRoutableValue})` : '';
151
+
140
152
  logger.info(
141
- `Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}`,
153
+ `Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}${viewNote}`,
142
154
  );
143
155
  const failures: string[] = [];
144
156
  for (const host of hostnames) {
145
157
  try {
146
- await dnsInternal.registerRecord({ host, type: 'A', value: natIp });
158
+ await dnsInternal.registerRecord({ host, type: 'A', value: natIp, zoneRoutableValue });
147
159
  } catch (err) {
148
160
  failures.push(`${host}: ${err instanceof Error ? err.message : String(err)}`);
149
161
  }
@@ -242,7 +242,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
242
242
  variables: {
243
243
  owns: [
244
244
  {
245
- name: 'idp_dmz_ip',
245
+ name: 'idp_auth_url',
246
246
  type: 'string',
247
247
  required: true,
248
248
  source: 'capability',
@@ -254,21 +254,21 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
254
254
  });
255
255
 
256
256
  it('fails when the consumed capability has no deployed provider', () => {
257
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
257
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
258
258
  const f = checkCapabilityProviders(db);
259
259
  expect(f.status).toBe('fail');
260
260
  expect(f.detail.join(' ')).toContain("no deployed module provides 'idp'");
261
261
  });
262
262
 
263
263
  it('fails when the provider lacks the referenced field', () => {
264
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
264
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
265
265
  insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
266
266
  db.insert(capabilitiesTable)
267
267
  .values({
268
268
  moduleId: 'authentik',
269
269
  capabilityName: 'idp',
270
270
  version: '1.0.0',
271
- data: { auth_url: 'x' },
271
+ data: { admin_email: 'x' },
272
272
  })
273
273
  .run();
274
274
  const f = checkCapabilityProviders(db);
@@ -277,14 +277,14 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
277
277
  });
278
278
 
279
279
  it('passes when the provider carries a concrete value', () => {
280
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
280
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
281
281
  insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
282
282
  db.insert(capabilitiesTable)
283
283
  .values({
284
284
  moduleId: 'authentik',
285
285
  capabilityName: 'idp',
286
286
  version: '1.0.0',
287
- data: { dmz_ip: '10.0.10.10' },
287
+ data: { auth_url: 'https://auth.celilo.computer' },
288
288
  })
289
289
  .run();
290
290
  const f = checkCapabilityProviders(db);
@@ -292,16 +292,16 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
292
292
  });
293
293
 
294
294
  it('passes but flags a derived ref for the chain trace (ISS-0114)', () => {
295
- // The workstream-B case: idp.dmz_ip is present but is itself a ref
296
- // ($self:caddy_dmz_ip) — we can't verify it resolves without the walker.
297
- insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
295
+ // idp.auth_url is present but is itself a ref ($self:auth_url) — we can't
296
+ // verify it resolves without the walker.
297
+ insertModule('forgejo', consumer('$capability:idp.auth_url'));
298
298
  insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
299
299
  db.insert(capabilitiesTable)
300
300
  .values({
301
301
  moduleId: 'authentik',
302
302
  capabilityName: 'idp',
303
303
  version: '1.0.0',
304
- data: { dmz_ip: '$self:caddy_dmz_ip' },
304
+ data: { auth_url: '$self:auth_url' },
305
305
  })
306
306
  .run();
307
307
  const f = checkCapabilityProviders(db);
@@ -442,11 +442,11 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
442
442
  variables: {
443
443
  owns: [
444
444
  {
445
- name: 'idp_dmz_ip',
445
+ name: 'idp_auth_url',
446
446
  type: 'string',
447
447
  required: true,
448
448
  source: 'capability',
449
- derive_from: '$capability:idp.dmz_ip',
449
+ derive_from: '$capability:idp.auth_url',
450
450
  },
451
451
  ],
452
452
  imports: [],
@@ -461,21 +461,23 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
461
461
  });
462
462
 
463
463
  it('flags empty-value when the field is present but empty', () => {
464
- const problems = findBrokenCapabilityDerivations('forgejo', consumer, { idp: { dmz_ip: '' } });
464
+ const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
465
+ idp: { auth_url: '' },
466
+ });
465
467
  expect(problems[0].reason).toBe('empty-value');
466
468
  });
467
469
 
468
470
  it('flags unresolved-ref when the resolved value is still a template', () => {
469
471
  const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
470
- idp: { dmz_ip: '$self:caddy_dmz_ip' },
472
+ idp: { auth_url: '$self:auth_url' },
471
473
  });
472
474
  expect(problems[0].reason).toBe('unresolved-ref');
473
- expect(problems[0].value).toBe('$self:caddy_dmz_ip');
475
+ expect(problems[0].value).toBe('$self:auth_url');
474
476
  });
475
477
 
476
478
  it('returns nothing when the field resolves to a concrete value', () => {
477
479
  const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
478
- idp: { dmz_ip: '10.0.10.10' },
480
+ idp: { auth_url: 'https://auth.celilo.computer' },
479
481
  });
480
482
  expect(problems).toHaveLength(0);
481
483
  });
@@ -27,23 +27,45 @@ export class DetectionError extends Error {
27
27
  export type CommandRunner = (command: string) => string;
28
28
 
29
29
  /**
30
- * Execute SSH command and return output
30
+ * Per-command SSH timeout. Detection opens a fresh SSH connection per
31
+ * attribute (hostname, cpu, memory, ...); under load (e.g. the e2e builder
32
+ * running many containers) a single handshake + command can exceed a tight
33
+ * budget, surfacing as `spawnSync ... ETIMEDOUT` and failing an
34
+ * otherwise-healthy `machine add`. 30s gives headroom without masking a
35
+ * genuinely-unreachable host (which fails fast on connection refusal).
36
+ */
37
+ const SSH_TIMEOUT_MS = 30_000;
38
+ /** Retry attempts for a detection SSH command. Detection is read-only
39
+ * (hostname / nproc / free / ...), so retrying a transient timeout is safe. */
40
+ const SSH_ATTEMPTS = 3;
41
+
42
+ /** Synchronous sleep — detection runs synchronously (execSync), so there's no
43
+ * async context to await in. */
44
+ function sleepSync(ms: number): void {
45
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
46
+ }
47
+
48
+ /**
49
+ * Execute SSH command and return output. Retries transient failures
50
+ * (connection timeouts under load) with linear backoff — safe because every
51
+ * detection command is read-only.
31
52
  */
32
53
  function sshExec(ip: string, user: string, keyPath: string, command: string): string {
33
- try {
34
- const output = execSync(
35
- `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -i "${keyPath}" ${user}@${ip} "${command}"`,
36
- {
54
+ const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -i "${keyPath}" ${user}@${ip} "${command}"`;
55
+ let lastMessage = 'Unknown error';
56
+ for (let attempt = 1; attempt <= SSH_ATTEMPTS; attempt++) {
57
+ try {
58
+ return execSync(sshCmd, {
37
59
  encoding: 'utf8',
38
60
  stdio: ['pipe', 'pipe', 'pipe'],
39
- timeout: 10000, // 10 second timeout
40
- },
41
- );
42
- return output.trim();
43
- } catch (error) {
44
- const message = error instanceof Error ? error.message : 'Unknown error';
45
- throw new DetectionError(`SSH command failed: ${message}`);
61
+ timeout: SSH_TIMEOUT_MS,
62
+ }).trim();
63
+ } catch (error) {
64
+ lastMessage = error instanceof Error ? error.message : 'Unknown error';
65
+ if (attempt < SSH_ATTEMPTS) sleepSync(1000 * attempt);
66
+ }
46
67
  }
68
+ throw new DetectionError(`SSH command failed after ${SSH_ATTEMPTS} attempts: ${lastMessage}`);
47
69
  }
48
70
 
49
71
  /** CommandRunner that SSHes to a remote machine. */