@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.
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/command-registry.ts +13 -2
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +5 -0
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader.ts +24 -15
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/templates/generator.ts +21 -0
|
@@ -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
|
|
204
|
-
// every successful registerHost is recorded
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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] =
|
|
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] =
|
|
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
|
+
}
|