@celilo/cli 0.8.1 → 0.9.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 (34) hide show
  1. package/AGENTS.md +86 -0
  2. package/CELILO_CORE_MODULES.md +61 -0
  3. package/CELILO_SUBSYSTEMS.md +83 -0
  4. package/drizzle/0012_module_systems_sizing.sql +3 -0
  5. package/drizzle/0013_dns_view_overrides.sql +1 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +6 -3
  8. package/src/capabilities/well-known.test.ts +12 -7
  9. package/src/capabilities/well-known.ts +11 -3
  10. package/src/cli/command-registry.ts +65 -1
  11. package/src/cli/commands/module-upgrade.test.ts +29 -0
  12. package/src/cli/commands/module-upgrade.ts +57 -24
  13. package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
  14. package/src/cli/commands/proxmox-instance-list.ts +140 -0
  15. package/src/cli/commands/proxmox-instance-resize.ts +235 -0
  16. package/src/cli/commands/proxmox-node-list.ts +1 -34
  17. package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
  18. package/src/cli/commands/proxmox-resize-guards.ts +102 -0
  19. package/src/cli/commands/proxmox-service.ts +38 -0
  20. package/src/cli/completion.ts +11 -3
  21. package/src/cli/index.ts +15 -0
  22. package/src/db/schema.ts +21 -1
  23. package/src/hooks/capability-loader.ts +22 -0
  24. package/src/manifest/template-validator.test.ts +31 -1
  25. package/src/manifest/template-validator.ts +9 -0
  26. package/src/services/deployed-systems.test.ts +73 -1
  27. package/src/services/deployed-systems.ts +72 -0
  28. package/src/services/dns-internal-records.test.ts +76 -3
  29. package/src/services/dns-internal-records.ts +52 -3
  30. package/src/services/dns-provider-backfill.ts +15 -3
  31. package/src/services/fleet-checks.test.ts +18 -16
  32. package/src/services/machine-detector.ts +34 -12
  33. package/src/templates/generator.ts +49 -1
  34. package/src/variables/context.ts +36 -7
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared Proxmox-service resolution for the `celilo proxmox …` command tree
3
+ * (node list, vm/ct list|show|resize). Resolves an explicit service-id arg, else
4
+ * the sole Proxmox service; errors when ambiguous or unknown.
5
+ */
6
+
7
+ import type { listContainerServices } from '../../services/container-service';
8
+
9
+ type Services = Awaited<ReturnType<typeof listContainerServices>>;
10
+
11
+ export function resolveProxmoxService(
12
+ services: Services,
13
+ requested: string | undefined,
14
+ ): { service: Services[number] } | { error: string } {
15
+ const proxmox = services.filter((s) => s.providerName === 'proxmox');
16
+ if (proxmox.length === 0) {
17
+ return {
18
+ error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
19
+ };
20
+ }
21
+ if (requested) {
22
+ const match = proxmox.find((s) => s.serviceId === requested);
23
+ if (!match) {
24
+ return {
25
+ error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
26
+ };
27
+ }
28
+ return { service: match };
29
+ }
30
+ if (proxmox.length > 1) {
31
+ return {
32
+ error: `Multiple Proxmox services — specify one with <service-id>:\n ${proxmox
33
+ .map((s) => s.serviceId)
34
+ .join('\n ')}`,
35
+ };
36
+ }
37
+ return { service: proxmox[0] };
38
+ }
@@ -248,13 +248,21 @@ export async function getCompletions(words: string[], current: number): Promise<
248
248
  // Service subcommands
249
249
  // Proxmox subcommands
250
250
  if (command === 'proxmox' && currentIndex === 1) {
251
- return filterSuggestions(['node'], args[1] || '');
251
+ return filterSuggestions(['node', 'vm', 'ct'], args[1] || '');
252
252
  }
253
253
  if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
254
254
  return filterSuggestions(['list'], args[2] || '');
255
255
  }
256
- // proxmox node list <service-id> proxmox services only
257
- if (command === 'proxmox' && args[1] === 'node' && args[2] === 'list' && currentIndex === 3) {
256
+ if (command === 'proxmox' && (args[1] === 'vm' || args[1] === 'ct') && currentIndex === 2) {
257
+ return filterSuggestions(['list', 'resize'], args[2] || '');
258
+ }
259
+ // proxmox <node|vm|ct> list <service-id> — proxmox services only
260
+ if (
261
+ command === 'proxmox' &&
262
+ (args[1] === 'node' || args[1] === 'vm' || args[1] === 'ct') &&
263
+ args[2] === 'list' &&
264
+ currentIndex === 3
265
+ ) {
258
266
  const services = await listContainerServices();
259
267
  const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
260
268
  return filterSuggestions(serviceIds, args[3] || '');
package/src/cli/index.ts CHANGED
@@ -69,6 +69,8 @@ import { handleModuleUpgrade } from './commands/module-upgrade';
69
69
  import { moduleVerify } from './commands/module-verify';
70
70
  import { handleModuleVersion } from './commands/module-version';
71
71
  import { handlePackage } from './commands/package';
72
+ import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
73
+ import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
72
74
  import { handleProxmoxNodeList } from './commands/proxmox-node-list';
73
75
  import { main as runPublish } from './commands/publish';
74
76
  import { handleSecretList } from './commands/secret-list';
@@ -1822,6 +1824,19 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1822
1824
  error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
1823
1825
  };
1824
1826
  }
1827
+ if (parsed.subcommand === 'vm' || parsed.subcommand === 'ct') {
1828
+ const action = parsed.args[0];
1829
+ if (action === 'list') {
1830
+ return handleProxmoxInstanceList(parsed.subcommand, parsed.args.slice(1));
1831
+ }
1832
+ if (action === 'resize') {
1833
+ return handleProxmoxInstanceResize(parsed.subcommand, parsed.args.slice(1), parsed.flags);
1834
+ }
1835
+ return {
1836
+ success: false,
1837
+ error: `Proxmox ${parsed.subcommand} action required (list, resize)\n\nRun "celilo proxmox --help" for usage`,
1838
+ };
1839
+ }
1825
1840
  return {
1826
1841
  success: false,
1827
1842
  error: `Unknown proxmox subcommand: ${parsed.subcommand}\n\nRun "celilo proxmox --help" for usage`,
package/src/db/schema.ts CHANGED
@@ -392,6 +392,17 @@ export const moduleSystems = sqliteTable(
392
392
  serviceId: text('service_id').references(() => containerServices.id),
393
393
  /** Proxmox VMID — set only for proxmox containers. */
394
394
  vmid: integer('vmid'),
395
+ // Canonical deployed SIZE of this system (ISS-0150). For celilo-provisioned
396
+ // VM/LXC instances only (null for machine-pool systems celilo doesn't size).
397
+ // Seeded from the module's `requires.system` at first provision, then owned
398
+ // by `celilo proxmox … resize` — `requires.system` is only the minimum floor,
399
+ // never the live size. See CLAUDE.md "requires.system is the MINIMUM".
400
+ /** vCPU cores. */
401
+ cpu: integer('cpu'),
402
+ /** RAM in MB. */
403
+ memory: integer('memory'),
404
+ /** Root disk in GB. */
405
+ disk: integer('disk'),
395
406
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
396
407
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
397
408
  },
@@ -492,8 +503,17 @@ export const dnsInternalRecords = sqliteTable(
492
503
  .references(() => modules.id, { onDelete: 'cascade' }),
493
504
  /** The registered hostname (e.g. "git-ssh.git.celilo.computer"). */
494
505
  host: text('host').notNull(),
495
- /** The A-record value celilo asked the resolver to serve. */
506
+ /** The A-record value celilo asked the resolver to serve — the LAN/default
507
+ * answer (firewall natIp for a caddy-fronted host). */
496
508
  ip: text('ip').notNull(),
509
+ /**
510
+ * In-zone split-horizon answer (caddy's zone-routable IP), when this is a
511
+ * caddy-fronted hostname that needs source-based views (ISS-0156,
512
+ * v2/INTERNAL_DNS_ZONE_VIEWS.md). NULL for records with no zone override
513
+ * (per-system identity, plain A records). This column is the durable
514
+ * desired-state the resolver's view config is reconciled from.
515
+ */
516
+ zoneRoutableIp: text('zone_routable_ip'),
497
517
  registeredAt: integer('registered_at', { mode: 'timestamp' })
498
518
  .notNull()
499
519
  .default(sql`(unixepoch())`),
@@ -124,6 +124,28 @@ export async function resolveFirewallNatIp(db: DbClient): Promise<string | undef
124
124
  return undefined;
125
125
  }
126
126
 
127
+ /**
128
+ * Caddy's zone-routable IP — its own DMZ ingress address (`target_ip`, the same
129
+ * value public_web exposes as `dmz_ip`). This is the in-zone split-horizon
130
+ * answer (ISS-0156): clients INSIDE the segmented zones reach caddy here, since
131
+ * they can't route to the firewall natIp. Returns undefined when no public_web
132
+ * provider advertises a `target_ip`. Shared by the live public_web registration
133
+ * and the deploy-time backfill so both write the same `zoneRoutableValue`.
134
+ */
135
+ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefined> {
136
+ const webProviders = db
137
+ .select()
138
+ .from(capabilities)
139
+ .where(eq(capabilities.capabilityName, 'public_web'))
140
+ .all();
141
+ for (const wp of webProviders) {
142
+ const cfg = await loadModuleConfig(wp.moduleId, db);
143
+ const ip = String(cfg.target_ip ?? '').split('/')[0];
144
+ if (ip) return ip;
145
+ }
146
+ return undefined;
147
+ }
148
+
127
149
  export async function loadCapabilityFunctions(
128
150
  consumingModuleId: string,
129
151
  db: DbClient,
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import type { ModuleManifest } from './schema';
3
- import { type TemplateValidationError, formatTemplateValidationErrors } from './template-validator';
6
+ import {
7
+ type TemplateValidationError,
8
+ formatTemplateValidationErrors,
9
+ validateModuleTemplates,
10
+ } from './template-validator';
4
11
 
5
12
  /**
6
13
  * Create a minimal valid manifest for testing
@@ -66,6 +73,29 @@ describe('template-validator', () => {
66
73
  expect(system?.storage).toBe('local-lvm');
67
74
  });
68
75
 
76
+ test('accepts $self:{cores,memory,disk,storage} as auto-allocated sizing (ISS-0150)', async () => {
77
+ // ISS-0150 repointed instance Terraform to read $self:cores/memory/disk/
78
+ // storage, which are injected at generate time from module_systems (see
79
+ // variables/context.ts) — not declared in the manifest. They must validate
80
+ // at `module import` time like vmid/target_ip, or every VM/CT module fails
81
+ // to import. Regression guard: this previously errored "Self variable
82
+ // 'cores' not found in module configuration".
83
+ const manifest = createTestManifest();
84
+ const dir = await mkdtemp(join(tmpdir(), 'celilo-tpl-'));
85
+ try {
86
+ await mkdir(join(dir, 'terraform'), { recursive: true });
87
+ await writeFile(
88
+ join(dir, 'terraform/main.tf.tpl'),
89
+ 'cores = $self:cores\nmemory = $self:memory\ndisk = $self:disk\nstorage = "$self:storage"\n',
90
+ );
91
+ const result = await validateModuleTemplates(dir, manifest);
92
+ expect(result.errors).toEqual([]);
93
+ expect(result.success).toBe(true);
94
+ } finally {
95
+ await rm(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+
69
99
  test('validates capability references', async () => {
70
100
  const manifest = createTestManifest({
71
101
  requires: {
@@ -73,6 +73,15 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
73
73
  'gateway', // Auto-derived from zone configuration
74
74
  'target_node', // Can be auto-derived from system config
75
75
  'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (v2/LXC_INTERNAL_DNS.md)
76
+ // Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
77
+ // disk,storage}, which are injected during resolution from the module_systems
78
+ // table (falling back to requires.system.*) — see variables/context.ts. Like
79
+ // vmid/target_ip they are populated at generate time, not declared in the
80
+ // manifest, so they are auto-allocated rather than import-time validation errors.
81
+ 'cores', // requires.system.cpu / module_systems.cpu
82
+ 'memory', // requires.system.memory / module_systems.memory
83
+ 'disk', // requires.system.disk / module_systems.disk
84
+ 'storage', // storage pool name (requires.system.storage)
76
85
  ]);
77
86
 
78
87
  /**
@@ -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
  };