@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.5

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Schema introspection — compare the DB's actual tables/columns against the
3
+ * tables the running code's drizzle schema declares. The single source for
4
+ * "what does the code expect, and is it present?" shared by:
5
+ * - the migration baseline (db/migrate.ts) — to decide which migrations are
6
+ * already applied on an existing DB before running migrate() (ISS-0100), and
7
+ * - the doctor's schema-drift check (services/fleet-checks.ts) — to report
8
+ * missing tables/columns to the operator (ISS-0113).
9
+ */
10
+
11
+ import type { Database } from 'bun:sqlite';
12
+ import { is } from 'drizzle-orm';
13
+ import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
14
+ import * as dbSchema from './schema';
15
+
16
+ export interface SchemaDrift {
17
+ /** Tables the code's schema declares that the DB lacks. */
18
+ missingTables: string[];
19
+ /** `table.column` the code declares that the DB's table lacks. */
20
+ missingColumns: string[];
21
+ /** Total number of tables the code's schema declares. */
22
+ tableCount: number;
23
+ }
24
+
25
+ /** Every table name + column names the drizzle schema declares. */
26
+ export function getSchemaTables(): Array<{ name: string; columns: string[] }> {
27
+ const out: Array<{ name: string; columns: string[] }> = [];
28
+ for (const value of Object.values(dbSchema)) {
29
+ if (!is(value, SQLiteTable)) continue;
30
+ const cfg = getTableConfig(value);
31
+ out.push({ name: cfg.name, columns: cfg.columns.map((c) => c.name) });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** Names of all tables that physically exist in the DB. */
37
+ export function getExistingTables(sqlite: Database): Set<string> {
38
+ return new Set(
39
+ sqlite
40
+ .query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table'")
41
+ .all()
42
+ .map((r) => r.name),
43
+ );
44
+ }
45
+
46
+ /** Names of all indexes that physically exist in the DB. */
47
+ export function getExistingIndexes(sqlite: Database): Set<string> {
48
+ return new Set(
49
+ sqlite
50
+ .query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='index'")
51
+ .all()
52
+ .map((r) => r.name),
53
+ );
54
+ }
55
+
56
+ /** Column names physically present on a table (empty if the table is absent). */
57
+ export function getExistingColumns(sqlite: Database, table: string): Set<string> {
58
+ // `table` is a schema/migration identifier (never user input) — safe to inline.
59
+ return new Set(
60
+ sqlite
61
+ .query<{ name: string }, []>(`SELECT name FROM pragma_table_info('${table}')`)
62
+ .all()
63
+ .map((r) => r.name),
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Compare the running code's drizzle schema to the DB and report what's missing.
69
+ * Track-agnostic: it reads actual table/column presence, so it's honest whether
70
+ * the DB was migrated, baselined, or hand-patched.
71
+ */
72
+ export function findSchemaDrift(sqlite: Database): SchemaDrift {
73
+ const present = getExistingTables(sqlite);
74
+ const missingTables: string[] = [];
75
+ const missingColumns: string[] = [];
76
+ const tables = getSchemaTables();
77
+ for (const t of tables) {
78
+ if (!present.has(t.name)) {
79
+ missingTables.push(t.name);
80
+ continue;
81
+ }
82
+ const cols = getExistingColumns(sqlite, t.name);
83
+ for (const c of t.columns) {
84
+ if (!cols.has(c)) missingColumns.push(`${t.name}.${c}`);
85
+ }
86
+ }
87
+ return { missingTables, missingColumns, tableCount: tables.length };
88
+ }
package/src/db/schema.ts CHANGED
@@ -468,6 +468,44 @@ export const dnsRegistrations = sqliteTable(
468
468
  }),
469
469
  );
470
470
 
471
+ /**
472
+ * Internal split-horizon DNS A-record ledger. Mirrors `dns_registrations`
473
+ * but for the dns_internal capability (technitium/knot): the capability
474
+ * loader records every `dns_internal.registerRecord({type:'A'})` here so
475
+ * celilo has an offline, queryable record of what hostname → IP it asked
476
+ * the internal resolver to serve. Without this, the only source of truth
477
+ * is the resolver's own DB, requiring a live probe (ISS-0094 / ISS-0111).
478
+ *
479
+ * `celilo system doctor` reads this to assert service hostnames resolve to
480
+ * the firewall natIp (LAN-reachable) and not a zone-side container IP that
481
+ * a LAN device can't route to. Rows die with either module via FK cascade.
482
+ */
483
+ export const dnsInternalRecords = sqliteTable(
484
+ 'dns_internal_records',
485
+ {
486
+ id: integer('id').primaryKey({ autoIncrement: true }),
487
+ providerModuleId: text('provider_module_id')
488
+ .notNull()
489
+ .references(() => modules.id, { onDelete: 'cascade' }),
490
+ consumerModuleId: text('consumer_module_id')
491
+ .notNull()
492
+ .references(() => modules.id, { onDelete: 'cascade' }),
493
+ /** The registered hostname (e.g. "git-ssh.git.celilo.computer"). */
494
+ host: text('host').notNull(),
495
+ /** The A-record value celilo asked the resolver to serve. */
496
+ ip: text('ip').notNull(),
497
+ registeredAt: integer('registered_at', { mode: 'timestamp' })
498
+ .notNull()
499
+ .default(sql`(unixepoch())`),
500
+ },
501
+ (table) => ({
502
+ providerHostUnique: uniqueIndex('dns_internal_records_provider_host_idx').on(
503
+ table.providerModuleId,
504
+ table.host,
505
+ ),
506
+ }),
507
+ );
508
+
471
509
  /**
472
510
  * Backup storage providers - destinations for backup archives
473
511
  * Supports local filesystem and S3-compatible storage (AWS S3, MinIO, Backblaze B2, Wasabi)
@@ -31,6 +31,7 @@ import { decryptSecret } from '../secrets/encryption';
31
31
  import { getOrCreateMasterKey } from '../secrets/master-key';
32
32
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
33
33
  import { getModuleSystems } from '../services/deployed-systems';
34
+ import { withDnsInternalLedger } from '../services/dns-internal-records';
34
35
  import { withDnsRegistrationLedger } from '../services/dns-registrations';
35
36
  import { loadHookConfigMap } from './load-hook-config';
36
37
 
@@ -200,19 +201,27 @@ export async function loadCapabilityFunctions(
200
201
  const providerConfig = await loadModuleConfig(capability.moduleId, db);
201
202
  const providerSecrets = await loadModuleSecrets(capability.moduleId, masterKey, db);
202
203
 
203
- // dns_registrar interfaces get the registration-ledger wrapper:
204
- // every successful registerHost is recorded in dns_registrations so
205
- // the provider's refresh_registrations hook can re-assert it later
206
- // (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2). The loader is
207
- // the one layer that knows both provider and consumer.
208
- const withLedgerIfDnsRegistrar = (iface: unknown): unknown =>
209
- capName === 'dns_registrar'
210
- ? withDnsRegistrationLedger(iface as DnsRegistrarCapability, {
211
- db,
212
- providerModuleId: capability.moduleId,
213
- consumerModuleId: consumingModuleId,
214
- })
215
- : iface;
204
+ // dns_registrar and dns_internal interfaces get a registration-ledger
205
+ // wrapper: every successful registerHost / registerRecord is recorded so
206
+ // celilo has an offline record of what it asked DNS to serve. The
207
+ // external ledger feeds the provider's refresh_registrations hook
208
+ // (DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2); the internal ledger feeds
209
+ // the doctor's natIp drift check (CELILO_DOCTOR_FLEET_DRIFT.md Phase 4).
210
+ // The loader is the one layer that knows both provider and consumer.
211
+ const ledgerCtx = {
212
+ db,
213
+ providerModuleId: capability.moduleId,
214
+ consumerModuleId: consumingModuleId,
215
+ };
216
+ const withLedger = (iface: unknown): unknown => {
217
+ if (capName === 'dns_registrar') {
218
+ return withDnsRegistrationLedger(iface as DnsRegistrarCapability, ledgerCtx);
219
+ }
220
+ if (capName === 'dns_internal') {
221
+ return withDnsInternalLedger(iface as DnsInternalCapability, ledgerCtx);
222
+ }
223
+ return iface;
224
+ };
216
225
 
217
226
  try {
218
227
  // Dynamically import the capability module. Try the default export
@@ -237,7 +246,7 @@ export async function loadCapabilityFunctions(
237
246
  systems: getModuleSystems(capability.moduleId, db),
238
247
  logger,
239
248
  });
240
- result[capName] = withLedgerIfDnsRegistrar(capabilityInterface);
249
+ result[capName] = withLedger(capabilityInterface);
241
250
  debugLog(`${capName}: loaded via defineCapabilityFunction`);
242
251
  continue;
243
252
  }
@@ -253,7 +262,7 @@ export async function loadCapabilityFunctions(
253
262
  );
254
263
 
255
264
  if (capabilityInterface) {
256
- result[capName] = withLedgerIfDnsRegistrar(
265
+ result[capName] = withLedger(
257
266
  wrapWithLogging(capabilityInterface as object, logger, capName),
258
267
  );
259
268
  debugLog(`${capName}: loaded via legacy factory`);
@@ -29,6 +29,7 @@ import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
29
29
  import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
30
30
  import { buildResolutionContext } from '../variables/context';
31
31
  import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
32
+ import { describeCapabilityProblem, findBrokenCapabilityDerivations } from './fleet-checks';
32
33
 
33
34
  export interface PreflightResult {
34
35
  success: boolean;
@@ -232,6 +233,30 @@ export async function runPreflight(
232
233
  });
233
234
  }
234
235
  }
236
+
237
+ // 4b. Capability-derived variables resolve to a concrete value. The
238
+ // scan above only catches values that ARE present-but-templated; a
239
+ // required `source: capability` var whose chain is broken upstream is
240
+ // silently DROPPED during derivation (the hasUnresolved guard), so it's
241
+ // absent from selfConfig entirely and a template `$self:<x>` later dies
242
+ // with a cryptic "not found". Assert it here against the RESOLVED
243
+ // capabilities map so the operator gets the named missing link up front
244
+ // instead of at generate time (ISS-0095 / ISS-0115).
245
+ for (const problem of findBrokenCapabilityDerivations(
246
+ moduleId,
247
+ manifest,
248
+ context.capabilities,
249
+ )) {
250
+ errors.push({
251
+ category: problem.reason === 'no-provider' ? 'missing-capability' : 'unresolved-template',
252
+ message: describeCapabilityProblem(problem),
253
+ variable: problem.variable,
254
+ suggestion:
255
+ problem.reason === 'no-provider'
256
+ ? `Deploy a module that provides '${problem.capability}', then redeploy '${moduleId}'`
257
+ : `Redeploy '${problem.capability}'s provider so it populates ${problem.capability}.${problem.path}, then redeploy '${moduleId}'`,
258
+ });
259
+ }
235
260
  } catch (error) {
236
261
  // Resolution context may fail — that's informative too
237
262
  warnings.push({
@@ -0,0 +1,126 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DnsRecordRequest } from '@celilo/capabilities';
6
+ import type { DbClient } from '../db/client';
7
+ import { modules } from '../db/schema';
8
+ import { setupTestDatabase } from '../test-utils/setup-test-db';
9
+ import {
10
+ listDnsInternalRecords,
11
+ recordDnsInternalRecord,
12
+ removeDnsInternalRecord,
13
+ withDnsInternalLedger,
14
+ } from './dns-internal-records';
15
+
16
+ describe('dns-internal-records ledger', () => {
17
+ let dir: string;
18
+ let dbPath: string;
19
+ let db: DbClient;
20
+
21
+ beforeEach(async () => {
22
+ dir = mkdtempSync(join(tmpdir(), 'dns-int-'));
23
+ dbPath = join(dir, 'celilo.db');
24
+ process.env.CELILO_DB_PATH = dbPath;
25
+ db = await setupTestDatabase(dbPath);
26
+ // FK targets: provider + consumer modules.
27
+ for (const id of ['technitium', 'forgejo']) {
28
+ db.insert(modules)
29
+ .values({
30
+ id,
31
+ name: id,
32
+ version: '1.0.0',
33
+ state: 'VERIFIED',
34
+ manifestData: {},
35
+ sourcePath: `/src/${id}`,
36
+ })
37
+ .run();
38
+ }
39
+ });
40
+ afterEach(() => {
41
+ db.$client.close();
42
+ process.env.CELILO_DB_PATH = undefined;
43
+ try {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ } catch {
46
+ /* ignore */
47
+ }
48
+ });
49
+
50
+ const ctx = () => ({ db, providerModuleId: 'technitium', consumerModuleId: 'forgejo' });
51
+
52
+ it('records, lists, and upserts by (provider, host)', () => {
53
+ recordDnsInternalRecord(db, { ...ctx(), host: 'git-ssh.x', ip: '10.0.20.14' });
54
+ let rows = listDnsInternalRecords(db);
55
+ expect(rows).toHaveLength(1);
56
+ expect(rows[0].ip).toBe('10.0.20.14');
57
+
58
+ // Same (provider, host) updates in place — the natIp fix re-registering.
59
+ recordDnsInternalRecord(db, { ...ctx(), host: 'git-ssh.x', ip: '192.168.0.253' });
60
+ rows = listDnsInternalRecords(db);
61
+ expect(rows).toHaveLength(1);
62
+ expect(rows[0].ip).toBe('192.168.0.253');
63
+ });
64
+
65
+ it('removes a record by (provider, host)', () => {
66
+ recordDnsInternalRecord(db, { ...ctx(), host: 'a.x', ip: '1.1.1.1' });
67
+ recordDnsInternalRecord(db, { ...ctx(), host: 'b.x', ip: '2.2.2.2' });
68
+ removeDnsInternalRecord(db, { providerModuleId: 'technitium', host: 'a.x' });
69
+ const rows = listDnsInternalRecords(db);
70
+ expect(rows.map((r) => r.host)).toEqual(['b.x']);
71
+ });
72
+
73
+ describe('withDnsInternalLedger', () => {
74
+ function fakeProvider() {
75
+ const calls: Array<['register' | 'delete', DnsRecordRequest]> = [];
76
+ const iface = {
77
+ async registerRecord(req: DnsRecordRequest) {
78
+ calls.push(['register', req]);
79
+ },
80
+ async deleteRecord(req: DnsRecordRequest) {
81
+ calls.push(['delete', req]);
82
+ },
83
+ };
84
+ return { iface, calls };
85
+ }
86
+
87
+ it('records A-record registrations and passes the call through', async () => {
88
+ const { iface, calls } = fakeProvider();
89
+ const wrapped = withDnsInternalLedger(iface, ctx());
90
+ await wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' });
91
+ expect(calls).toHaveLength(1); // underlying provider still called
92
+ const rows = listDnsInternalRecords(db);
93
+ expect(rows).toHaveLength(1);
94
+ expect(rows[0]).toMatchObject({ host: 'git-ssh.x', ip: '192.168.0.253' });
95
+ });
96
+
97
+ it('does NOT ledger non-A records', async () => {
98
+ const { iface } = fakeProvider();
99
+ const wrapped = withDnsInternalLedger(iface, ctx());
100
+ await wrapped.registerRecord({ host: 'mail.x', type: 'MX', value: 'mx.x' });
101
+ expect(listDnsInternalRecords(db)).toHaveLength(0);
102
+ });
103
+
104
+ it('removes the ledger row on A-record delete', async () => {
105
+ const { iface } = fakeProvider();
106
+ const wrapped = withDnsInternalLedger(iface, ctx());
107
+ await wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' });
108
+ await wrapped.deleteRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' });
109
+ expect(listDnsInternalRecords(db)).toHaveLength(0);
110
+ });
111
+
112
+ it('does not write the ledger if the underlying register throws', async () => {
113
+ const iface = {
114
+ async registerRecord(): Promise<void> {
115
+ throw new Error('resolver down');
116
+ },
117
+ async deleteRecord(): Promise<void> {},
118
+ };
119
+ const wrapped = withDnsInternalLedger(iface, ctx());
120
+ await expect(
121
+ wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '1.2.3.4' }),
122
+ ).rejects.toThrow('resolver down');
123
+ expect(listDnsInternalRecords(db)).toHaveLength(0);
124
+ });
125
+ });
126
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Internal split-horizon DNS A-record ledger (designs/CELILO_DOCTOR_FLEET_DRIFT.md
3
+ * Phase 4, ISS-0094 / ISS-0111).
4
+ *
5
+ * The dns_internal capability (technitium/knot) registers records straight
6
+ * into the resolver's own DB, leaving celilo no offline record of what it
7
+ * asked to be served. This ledger — the internal-DNS sibling of
8
+ * `dns_registrations` — records every successful `registerRecord({type:'A'})`
9
+ * so `celilo system doctor` can assert, without a live resolver probe, that
10
+ * service hostnames resolve to the firewall natIp (LAN-reachable) rather than
11
+ * a zone-side container IP a LAN device can't route to.
12
+ *
13
+ * Row lifecycle is FK cascade — records die with their provider or consumer.
14
+ */
15
+
16
+ import type { DnsInternalCapability, DnsRecordRequest } from '@celilo/capabilities';
17
+ import { and, eq } from 'drizzle-orm';
18
+ import type { DbClient } from '../db/client';
19
+ import { dnsInternalRecords } from '../db/schema';
20
+
21
+ export interface DnsInternalRecordRow {
22
+ host: string;
23
+ ip: string;
24
+ providerModuleId: string;
25
+ consumerModuleId: string;
26
+ registeredAt: Date;
27
+ }
28
+
29
+ export function listDnsInternalRecords(
30
+ db: DbClient,
31
+ options: { providerModuleId?: string } = {},
32
+ ): DnsInternalRecordRow[] {
33
+ const query = db
34
+ .select({
35
+ host: dnsInternalRecords.host,
36
+ ip: dnsInternalRecords.ip,
37
+ providerModuleId: dnsInternalRecords.providerModuleId,
38
+ consumerModuleId: dnsInternalRecords.consumerModuleId,
39
+ registeredAt: dnsInternalRecords.registeredAt,
40
+ })
41
+ .from(dnsInternalRecords);
42
+ return options.providerModuleId
43
+ ? query.where(eq(dnsInternalRecords.providerModuleId, options.providerModuleId)).all()
44
+ : query.all();
45
+ }
46
+
47
+ export function recordDnsInternalRecord(
48
+ db: DbClient,
49
+ record: { providerModuleId: string; consumerModuleId: string; host: string; ip: string },
50
+ ): void {
51
+ db.insert(dnsInternalRecords)
52
+ .values({
53
+ providerModuleId: record.providerModuleId,
54
+ consumerModuleId: record.consumerModuleId,
55
+ host: record.host,
56
+ ip: record.ip,
57
+ registeredAt: new Date(),
58
+ })
59
+ .onConflictDoUpdate({
60
+ target: [dnsInternalRecords.providerModuleId, dnsInternalRecords.host],
61
+ set: {
62
+ consumerModuleId: record.consumerModuleId,
63
+ ip: record.ip,
64
+ registeredAt: new Date(),
65
+ },
66
+ })
67
+ .run();
68
+ }
69
+
70
+ export function removeDnsInternalRecord(
71
+ db: DbClient,
72
+ record: { providerModuleId: string; host: string },
73
+ ): void {
74
+ db.delete(dnsInternalRecords)
75
+ .where(
76
+ and(
77
+ eq(dnsInternalRecords.providerModuleId, record.providerModuleId),
78
+ eq(dnsInternalRecords.host, record.host),
79
+ ),
80
+ )
81
+ .run();
82
+ }
83
+
84
+ /**
85
+ * Wrap a dns_internal interface so A-record register/delete calls are
86
+ * mirrored into the ledger. Only A records are tracked — they're the
87
+ * host→IP mapping the natIp drift check reasons about; CNAMEs/others pass
88
+ * through unrecorded. A throw from the underlying call propagates before
89
+ * any ledger write, so only a confirmed register/delete earns a row change.
90
+ */
91
+ export function withDnsInternalLedger(
92
+ iface: DnsInternalCapability,
93
+ ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
94
+ ): DnsInternalCapability {
95
+ const isA = (request: DnsRecordRequest) => request.type.toUpperCase() === 'A';
96
+ return {
97
+ ...iface,
98
+ async registerRecord(request: DnsRecordRequest): Promise<void> {
99
+ await iface.registerRecord(request);
100
+ if (isA(request)) {
101
+ recordDnsInternalRecord(ctx.db, {
102
+ providerModuleId: ctx.providerModuleId,
103
+ consumerModuleId: ctx.consumerModuleId,
104
+ host: request.host,
105
+ ip: request.value,
106
+ });
107
+ }
108
+ },
109
+ async deleteRecord(request: DnsRecordRequest): Promise<void> {
110
+ await iface.deleteRecord(request);
111
+ if (isA(request)) {
112
+ removeDnsInternalRecord(ctx.db, {
113
+ providerModuleId: ctx.providerModuleId,
114
+ host: request.host,
115
+ });
116
+ }
117
+ },
118
+ };
119
+ }