@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.6
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/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/cli/command-registry.ts +13 -2
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +2 -2
- package/src/cli/commands/service-add-proxmox.ts +9 -0
- 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 +7 -2
- package/src/config/paths.test.ts +61 -48
- 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-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +24 -15
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/schema.ts +7 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +2 -2
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +10 -10
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/templates/generator.test.ts +62 -12
- package/src/templates/generator.ts +69 -50
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -77,6 +77,59 @@ variables:
|
|
|
77
77
|
}
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test('requires.system.type defaults to lxc when omitted', () => {
|
|
81
|
+
const yaml = `
|
|
82
|
+
${CONTRACT_LINE}
|
|
83
|
+
id: homebridge
|
|
84
|
+
name: Homebridge
|
|
85
|
+
version: 1.0.0
|
|
86
|
+
requires:
|
|
87
|
+
system:
|
|
88
|
+
cpu: 1
|
|
89
|
+
zone: app
|
|
90
|
+
`;
|
|
91
|
+
const result = validateManifest(yaml);
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
if (result.success) {
|
|
94
|
+
expect(result.data.requires.system?.type).toBe('lxc');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('requires.system.type accepts an explicit vm', () => {
|
|
99
|
+
const yaml = `
|
|
100
|
+
${CONTRACT_LINE}
|
|
101
|
+
id: forgejo-runner
|
|
102
|
+
name: Forgejo Runner
|
|
103
|
+
version: 1.0.0
|
|
104
|
+
requires:
|
|
105
|
+
system:
|
|
106
|
+
cpu: 4
|
|
107
|
+
memory: 8192
|
|
108
|
+
type: vm
|
|
109
|
+
zone: dmz
|
|
110
|
+
`;
|
|
111
|
+
const result = validateManifest(yaml);
|
|
112
|
+
expect(result.success).toBe(true);
|
|
113
|
+
if (result.success) {
|
|
114
|
+
expect(result.data.requires.system?.type).toBe('vm');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('requires.system.type rejects an unknown infra type', () => {
|
|
119
|
+
const yaml = `
|
|
120
|
+
${CONTRACT_LINE}
|
|
121
|
+
id: homebridge
|
|
122
|
+
name: Homebridge
|
|
123
|
+
version: 1.0.0
|
|
124
|
+
requires:
|
|
125
|
+
system:
|
|
126
|
+
type: container
|
|
127
|
+
zone: app
|
|
128
|
+
`;
|
|
129
|
+
const result = validateManifest(yaml);
|
|
130
|
+
expect(result.success).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
80
133
|
test('should validate dns-external manifest with capability provider', () => {
|
|
81
134
|
const yaml = `
|
|
82
135
|
${CONTRACT_LINE}
|
|
@@ -42,7 +42,7 @@ describe('busInterview', () => {
|
|
|
42
42
|
const watch = responderBus.watch('config.required.lunacycle.domain', (event) => {
|
|
43
43
|
responderBus.emitRaw(
|
|
44
44
|
`${event.type}.reply`,
|
|
45
|
-
{ value: '
|
|
45
|
+
{ value: 'example.net' },
|
|
46
46
|
{ replyFor: event.id, emittedBy: 'test-responder' },
|
|
47
47
|
);
|
|
48
48
|
});
|
|
@@ -58,7 +58,7 @@ describe('busInterview', () => {
|
|
|
58
58
|
payload,
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
-
expect(reply.value).toBe('
|
|
61
|
+
expect(reply.value).toBe('example.net');
|
|
62
62
|
|
|
63
63
|
watch.close();
|
|
64
64
|
responderBus.close();
|
|
@@ -350,7 +350,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
350
350
|
const { seen, close } = startTestResponder(
|
|
351
351
|
bus,
|
|
352
352
|
'secret.required.testmod.ddns_passwords',
|
|
353
|
-
{ value: JSON.stringify({ '
|
|
353
|
+
{ value: JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }) },
|
|
354
354
|
testDb,
|
|
355
355
|
);
|
|
356
356
|
|
|
@@ -380,7 +380,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
380
380
|
|
|
381
381
|
const masterKey = await getOrCreateMasterKey();
|
|
382
382
|
const stored = await readModuleSecretKey('testmod', 'ddns_passwords', testDb, masterKey);
|
|
383
|
-
expect(stored).toBe(JSON.stringify({ '
|
|
383
|
+
expect(stored).toBe(JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }));
|
|
384
384
|
|
|
385
385
|
close();
|
|
386
386
|
});
|
|
@@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
|
18
18
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
21
22
|
|
|
22
23
|
import type { HookContext } from '@celilo/capabilities';
|
|
23
24
|
|
|
@@ -77,7 +78,7 @@ function buildContext(extras: Record<string, unknown>): HookContext {
|
|
|
77
78
|
} as HookContext;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
describe('celilo-mgmt on_backup', () => {
|
|
81
|
+
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', () => {
|
|
81
82
|
let dir: string;
|
|
82
83
|
let backupDir: string;
|
|
83
84
|
let crossModuleRoot: string;
|
|
@@ -214,7 +215,7 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
214
215
|
});
|
|
215
216
|
});
|
|
216
217
|
|
|
217
|
-
describe('celilo-mgmt on_restore', () => {
|
|
218
|
+
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
|
|
218
219
|
let dir: string;
|
|
219
220
|
let restoreDir: string;
|
|
220
221
|
let crossModuleWriteRoot: string;
|
|
@@ -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({
|
|
@@ -265,8 +265,8 @@ describe('findMissingSecrets (shared)', () => {
|
|
|
265
265
|
// The terminal-responder reads these off the bus payload to apply
|
|
266
266
|
// input-time regex validation. Without manifest → MissingVariable
|
|
267
267
|
// propagation, the responder never sees them and operators end
|
|
268
|
-
// up entering invalid keys (e.g. 'www.
|
|
269
|
-
// the apex '
|
|
268
|
+
// up entering invalid keys (e.g. 'www.example.net' instead of
|
|
269
|
+
// the apex 'example.net').
|
|
270
270
|
const missing = await findMissingSecrets(
|
|
271
271
|
'testmod',
|
|
272
272
|
{
|
|
@@ -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
|
+
}
|
|
@@ -105,14 +105,14 @@ describe('backfillWebRouteDns (ISS-0029)', () => {
|
|
|
105
105
|
seedFirewall('100.64.0.1');
|
|
106
106
|
seedRoute('apt.celilo.computer', '/');
|
|
107
107
|
seedRoute('apt.celilo.computer', '/-/publish'); // same host, different path → deduped
|
|
108
|
-
seedRoute('registry.
|
|
108
|
+
seedRoute('registry.example.net', '/');
|
|
109
109
|
|
|
110
110
|
const calls: DnsRecordRequest[] = [];
|
|
111
111
|
await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls));
|
|
112
112
|
|
|
113
113
|
expect(calls.map((c) => c.host).sort()).toEqual([
|
|
114
114
|
'apt.celilo.computer',
|
|
115
|
-
'registry.
|
|
115
|
+
'registry.example.net',
|
|
116
116
|
]);
|
|
117
117
|
expect(calls.every((c) => c.value === '100.64.0.1' && c.type === 'A')).toBe(true);
|
|
118
118
|
});
|
|
@@ -50,19 +50,19 @@ describe('dns_registrations ledger', () => {
|
|
|
50
50
|
recordDnsRegistration(db, {
|
|
51
51
|
providerModuleId: 'namecheap',
|
|
52
52
|
consumerModuleId: 'caddy',
|
|
53
|
-
fqdn: 'www.
|
|
53
|
+
fqdn: 'www.example.net',
|
|
54
54
|
ip: '198.51.100.7',
|
|
55
55
|
});
|
|
56
56
|
recordDnsRegistration(db, {
|
|
57
57
|
providerModuleId: 'namecheap',
|
|
58
58
|
consumerModuleId: 'caddy',
|
|
59
|
-
fqdn: 'www.
|
|
59
|
+
fqdn: 'www.example.net',
|
|
60
60
|
ip: '198.51.100.8',
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
const rows = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
|
|
64
64
|
expect(rows.length).toBe(1);
|
|
65
|
-
expect(rows[0].fqdn).toBe('www.
|
|
65
|
+
expect(rows[0].fqdn).toBe('www.example.net');
|
|
66
66
|
expect(rows[0].ip).toBe('198.51.100.8');
|
|
67
67
|
expect(rows[0].consumerModuleId).toBe('caddy');
|
|
68
68
|
expect(rows[0].refreshedAt).toBeNull();
|
|
@@ -72,7 +72,7 @@ describe('dns_registrations ledger', () => {
|
|
|
72
72
|
recordDnsRegistration(db, {
|
|
73
73
|
providerModuleId: 'namecheap',
|
|
74
74
|
consumerModuleId: 'caddy',
|
|
75
|
-
fqdn: 'git.
|
|
75
|
+
fqdn: 'git.example.net',
|
|
76
76
|
ip: null,
|
|
77
77
|
});
|
|
78
78
|
stampDnsRegistrationsRefreshed(db, 'namecheap');
|
|
@@ -84,7 +84,7 @@ describe('dns_registrations ledger', () => {
|
|
|
84
84
|
recordDnsRegistration(db, {
|
|
85
85
|
providerModuleId: 'namecheap',
|
|
86
86
|
consumerModuleId: 'caddy',
|
|
87
|
-
fqdn: 'www.
|
|
87
|
+
fqdn: 'www.example.net',
|
|
88
88
|
ip: '198.51.100.7',
|
|
89
89
|
});
|
|
90
90
|
db.delete(modules).where(eq(modules.id, 'caddy')).run();
|
|
@@ -106,15 +106,15 @@ describe('dns_registrations ledger', () => {
|
|
|
106
106
|
consumerModuleId: 'caddy',
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
await wrapped.registerHost({ fqdn: 'www.
|
|
110
|
-
await wrapped.registerHost({ fqdn: 'fail.
|
|
111
|
-
await wrapped.registerHost({ fqdn: 'auto.
|
|
109
|
+
await wrapped.registerHost({ fqdn: 'www.example.net', ip: '198.51.100.7' });
|
|
110
|
+
await wrapped.registerHost({ fqdn: 'fail.example.net', ip: '198.51.100.7' });
|
|
111
|
+
await wrapped.registerHost({ fqdn: 'auto.example.net' });
|
|
112
112
|
|
|
113
113
|
expect(calls.length).toBe(3);
|
|
114
114
|
const rows = listDnsRegistrations(db);
|
|
115
115
|
const fqdns = rows.map((r) => r.fqdn).sort();
|
|
116
|
-
expect(fqdns).toEqual(['auto.
|
|
117
|
-
const auto = rows.find((r) => r.fqdn === 'auto.
|
|
116
|
+
expect(fqdns).toEqual(['auto.example.net', 'www.example.net']);
|
|
117
|
+
const auto = rows.find((r) => r.fqdn === 'auto.example.net');
|
|
118
118
|
expect(auto?.ip).toBeNull();
|
|
119
119
|
});
|
|
120
120
|
});
|