@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.2
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/0009_dns_registrations.sql +13 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/api-clients/proxmox.test.ts +30 -0
- package/src/api-clients/proxmox.ts +57 -0
- package/src/cli/command-registry.ts +33 -1
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.ts +51 -19
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +16 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +55 -5
- package/src/db/schema.ts +36 -0
- package/src/hooks/capability-loader.ts +30 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +10 -0
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/module-validator/capability-versions.test.ts +1 -1
- package/src/templates/generator.test.ts +30 -3
- package/src/templates/generator.ts +80 -5
package/src/cli/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { COMMANDS, type CommandDef } from './command-registry';
|
|
|
10
10
|
import { handleCapabilityInfo } from './commands/capability-info';
|
|
11
11
|
import { handleCapabilityList } from './commands/capability-list';
|
|
12
12
|
import { handleCompletion } from './commands/completion';
|
|
13
|
+
import { handleDnsRegistrations } from './commands/dns';
|
|
13
14
|
import {
|
|
14
15
|
handleEventsAck,
|
|
15
16
|
handleEventsDrain,
|
|
@@ -162,6 +163,7 @@ Commands:
|
|
|
162
163
|
audit Top-level alias for 'system audit'
|
|
163
164
|
events SQLite event-bus operations (status, tail, run dispatcher, etc.)
|
|
164
165
|
capability View registered module capabilities
|
|
166
|
+
dns View DNS bookkeeping (registrations ledger)
|
|
165
167
|
package Create distributable .netapp packages from module source
|
|
166
168
|
module Manage modules (import, list, configure, build, generate)
|
|
167
169
|
service Manage container services (Proxmox, Digital Ocean)
|
|
@@ -265,9 +267,9 @@ Subcommands:
|
|
|
265
267
|
repair Crash-recovery sweep without starting the dispatcher
|
|
266
268
|
resume Alias for repair (acknowledges halt-on-recovery)
|
|
267
269
|
respond Run the terminal responder; answer deploy prompts from another shell
|
|
268
|
-
install-daemon
|
|
269
|
-
uninstall-daemon
|
|
270
|
-
show-daemon
|
|
270
|
+
install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
|
|
271
|
+
uninstall-daemon [--system] Remove the installed supervisor unit
|
|
272
|
+
show-daemon [--system] Print the currently installed unit file
|
|
271
273
|
|
|
272
274
|
Description:
|
|
273
275
|
The event bus is a SQLite-backed pub/sub layer for celilo modules.
|
|
@@ -380,6 +382,32 @@ Related Commands:
|
|
|
380
382
|
};
|
|
381
383
|
}
|
|
382
384
|
|
|
385
|
+
function displayDnsHelp(): CommandResult {
|
|
386
|
+
const helpText = `
|
|
387
|
+
Celilo - DNS Bookkeeping
|
|
388
|
+
|
|
389
|
+
Usage:
|
|
390
|
+
celilo dns <subcommand> [options]
|
|
391
|
+
|
|
392
|
+
Subcommands:
|
|
393
|
+
registrations List the DNS registration ledger
|
|
394
|
+
|
|
395
|
+
Description:
|
|
396
|
+
Every successful dns_registrar.registerHost (e.g. a deploy registering
|
|
397
|
+
its public hostname with namecheap) is recorded in the registration
|
|
398
|
+
ledger. The provider's refresh_registrations hook re-asserts these on
|
|
399
|
+
a timer; REFRESHED shows when that last succeeded.
|
|
400
|
+
|
|
401
|
+
Options:
|
|
402
|
+
--provider <module-id> Only show registrations owned by one provider
|
|
403
|
+
|
|
404
|
+
Examples:
|
|
405
|
+
celilo dns registrations
|
|
406
|
+
celilo dns registrations --provider namecheap
|
|
407
|
+
`;
|
|
408
|
+
return { success: true, message: helpText };
|
|
409
|
+
}
|
|
410
|
+
|
|
383
411
|
function displayCapabilityHelp(): CommandResult {
|
|
384
412
|
const helpText = `
|
|
385
413
|
Celilo - Capability Management
|
|
@@ -1075,6 +1103,28 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1075
1103
|
};
|
|
1076
1104
|
}
|
|
1077
1105
|
|
|
1106
|
+
if (parsed.command === 'dns') {
|
|
1107
|
+
if (parsed.flags.help || parsed.flags.h) {
|
|
1108
|
+
return displayDnsHelp();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (!parsed.subcommand) {
|
|
1112
|
+
return {
|
|
1113
|
+
success: false,
|
|
1114
|
+
error: 'DNS subcommand required\n\nRun "celilo dns --help" for usage',
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (parsed.subcommand === 'registrations') {
|
|
1119
|
+
return handleDnsRegistrations(parsed.args, parsed.flags);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
success: false,
|
|
1124
|
+
error: `Unknown dns subcommand: ${parsed.subcommand}\n\nRun "celilo dns --help" for usage`,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1078
1128
|
if (parsed.command === 'capability') {
|
|
1079
1129
|
if (parsed.flags.help || parsed.flags.h) {
|
|
1080
1130
|
return displayCapabilityHelp();
|
|
@@ -1168,9 +1218,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1168
1218
|
case 'install-daemon':
|
|
1169
1219
|
return handleEventsInstallDaemon(parsed.args, parsed.flags);
|
|
1170
1220
|
case 'uninstall-daemon':
|
|
1171
|
-
return handleEventsUninstallDaemon();
|
|
1221
|
+
return handleEventsUninstallDaemon(parsed.args, parsed.flags);
|
|
1172
1222
|
case 'show-daemon':
|
|
1173
|
-
return handleEventsShowDaemon();
|
|
1223
|
+
return handleEventsShowDaemon(parsed.args, parsed.flags);
|
|
1174
1224
|
default:
|
|
1175
1225
|
return {
|
|
1176
1226
|
success: false,
|
package/src/db/schema.ts
CHANGED
|
@@ -432,6 +432,42 @@ export const webRoutes = sqliteTable(
|
|
|
432
432
|
}),
|
|
433
433
|
);
|
|
434
434
|
|
|
435
|
+
/**
|
|
436
|
+
* DNS registration ledger — one row per (provider, fqdn) the framework
|
|
437
|
+
* has successfully registered via dns_registrar.registerHost. Written
|
|
438
|
+
* framework-side by the capability loader (the only layer that knows
|
|
439
|
+
* both consumer and provider), read back to drive the provider's
|
|
440
|
+
* periodic refresh_registrations hook and the `celilo dns
|
|
441
|
+
* registrations` view. Rows die with either module via FK cascade; the
|
|
442
|
+
* remote DNS record itself stays (Namecheap DDNS has no delete API).
|
|
443
|
+
* See designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B2).
|
|
444
|
+
*/
|
|
445
|
+
export const dnsRegistrations = sqliteTable(
|
|
446
|
+
'dns_registrations',
|
|
447
|
+
{
|
|
448
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
449
|
+
providerModuleId: text('provider_module_id')
|
|
450
|
+
.notNull()
|
|
451
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
452
|
+
consumerModuleId: text('consumer_module_id')
|
|
453
|
+
.notNull()
|
|
454
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
455
|
+
fqdn: text('fqdn').notNull(),
|
|
456
|
+
/** null = provider auto-detected the request's source IP (Namecheap-style). */
|
|
457
|
+
ip: text('ip'),
|
|
458
|
+
registeredAt: integer('registered_at', { mode: 'timestamp' })
|
|
459
|
+
.notNull()
|
|
460
|
+
.default(sql`(unixepoch())`),
|
|
461
|
+
refreshedAt: integer('refreshed_at', { mode: 'timestamp' }),
|
|
462
|
+
},
|
|
463
|
+
(table) => ({
|
|
464
|
+
providerFqdnUnique: uniqueIndex('dns_registrations_provider_fqdn_idx').on(
|
|
465
|
+
table.providerModuleId,
|
|
466
|
+
table.fqdn,
|
|
467
|
+
),
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
|
|
435
471
|
/**
|
|
436
472
|
* Backup storage providers - destinations for backup archives
|
|
437
473
|
* Supports local filesystem and S3-compatible storage (AWS S3, MinIO, Backblaze B2, Wasabi)
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from '@celilo/capabilities';
|
|
20
20
|
import type {
|
|
21
21
|
DnsInternalCapability,
|
|
22
|
+
DnsRegistrarCapability,
|
|
22
23
|
HookLogger,
|
|
23
24
|
RouteOps,
|
|
24
25
|
RouteReadView,
|
|
@@ -30,6 +31,7 @@ import { decryptSecret } from '../secrets/encryption';
|
|
|
30
31
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
31
32
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
32
33
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
34
|
+
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
33
35
|
import { loadHookConfigMap } from './load-hook-config';
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -194,6 +196,20 @@ export async function loadCapabilityFunctions(
|
|
|
194
196
|
const providerConfig = await loadModuleConfig(capability.moduleId, db);
|
|
195
197
|
const providerSecrets = await loadModuleSecrets(capability.moduleId, masterKey, db);
|
|
196
198
|
|
|
199
|
+
// dns_registrar interfaces get the registration-ledger wrapper:
|
|
200
|
+
// every successful registerHost is recorded in dns_registrations so
|
|
201
|
+
// the provider's refresh_registrations hook can re-assert it later
|
|
202
|
+
// (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2). The loader is
|
|
203
|
+
// the one layer that knows both provider and consumer.
|
|
204
|
+
const withLedgerIfDnsRegistrar = (iface: unknown): unknown =>
|
|
205
|
+
capName === 'dns_registrar'
|
|
206
|
+
? withDnsRegistrationLedger(iface as DnsRegistrarCapability, {
|
|
207
|
+
db,
|
|
208
|
+
providerModuleId: capability.moduleId,
|
|
209
|
+
consumerModuleId: consumingModuleId,
|
|
210
|
+
})
|
|
211
|
+
: iface;
|
|
212
|
+
|
|
197
213
|
try {
|
|
198
214
|
// Dynamically import the capability module. Try the default export
|
|
199
215
|
// first (the HOOK_API_V2 Phase 8 pattern), fall back to the legacy
|
|
@@ -217,7 +233,7 @@ export async function loadCapabilityFunctions(
|
|
|
217
233
|
systems: getModuleSystems(capability.moduleId, db),
|
|
218
234
|
logger,
|
|
219
235
|
});
|
|
220
|
-
result[capName] = capabilityInterface;
|
|
236
|
+
result[capName] = withLedgerIfDnsRegistrar(capabilityInterface);
|
|
221
237
|
debugLog(`${capName}: loaded via defineCapabilityFunction`);
|
|
222
238
|
continue;
|
|
223
239
|
}
|
|
@@ -233,7 +249,9 @@ export async function loadCapabilityFunctions(
|
|
|
233
249
|
);
|
|
234
250
|
|
|
235
251
|
if (capabilityInterface) {
|
|
236
|
-
result[capName] =
|
|
252
|
+
result[capName] = withLedgerIfDnsRegistrar(
|
|
253
|
+
wrapWithLogging(capabilityInterface as object, logger, capName),
|
|
254
|
+
);
|
|
237
255
|
debugLog(`${capName}: loaded via legacy factory`);
|
|
238
256
|
}
|
|
239
257
|
} catch (error) {
|
|
@@ -388,6 +406,16 @@ export async function loadCapabilityFunctions(
|
|
|
388
406
|
`public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
|
|
389
407
|
);
|
|
390
408
|
}
|
|
409
|
+
// ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
|
|
410
|
+
// providers reconciled it (no subscriber consumed routes_changed). That
|
|
411
|
+
// registers a route nobody applied and would otherwise report success.
|
|
412
|
+
// `events === 0` means no routes changed (benign); `events > 0 &&
|
|
413
|
+
// succeeded === 0` means the route changed but nobody served it — a failure.
|
|
414
|
+
if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`public_web route for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the route is persisted yet never served (no site block, no cert). Ensure a public_web provider (caddy) is deployed and subscribed.`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
391
419
|
},
|
|
392
420
|
});
|
|
393
421
|
debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
|
|
@@ -19,6 +19,10 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
19
19
|
import { decryptSecret } from '../secrets/encryption';
|
|
20
20
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
21
21
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
22
|
+
import {
|
|
23
|
+
listDnsRegistrations,
|
|
24
|
+
stampDnsRegistrationsRefreshed,
|
|
25
|
+
} from '../services/dns-registrations';
|
|
22
26
|
import { loadCapabilityFunctions } from './capability-loader';
|
|
23
27
|
import { invokeHook } from './executor';
|
|
24
28
|
import { loadHookConfigMap } from './load-hook-config';
|
|
@@ -125,12 +129,26 @@ export async function runNamedHook(
|
|
|
125
129
|
: [];
|
|
126
130
|
const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
|
|
127
131
|
|
|
128
|
-
|
|
132
|
+
// refresh_registrations is fed by the framework: module scripts can't
|
|
133
|
+
// read the celilo DB, so the dns_registrations ledger rows for THIS
|
|
134
|
+
// provider are injected as the contract's `registrations` input
|
|
135
|
+
// (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). Authoritative —
|
|
136
|
+
// overrides any caller-supplied value.
|
|
137
|
+
let inputs = options.inputs ?? {};
|
|
138
|
+
if (hookName === 'refresh_registrations') {
|
|
139
|
+
const registrations = listDnsRegistrations(db, { providerModuleId: moduleId }).map((r) => ({
|
|
140
|
+
fqdn: r.fqdn,
|
|
141
|
+
ip: r.ip,
|
|
142
|
+
}));
|
|
143
|
+
inputs = { ...inputs, registrations };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = await invokeHook(
|
|
129
147
|
module.sourcePath,
|
|
130
148
|
hookName,
|
|
131
149
|
manifest.celilo_contract,
|
|
132
150
|
hookDef,
|
|
133
|
-
|
|
151
|
+
inputs,
|
|
134
152
|
configMap,
|
|
135
153
|
secretMap,
|
|
136
154
|
logger,
|
|
@@ -141,4 +159,12 @@ export async function runNamedHook(
|
|
|
141
159
|
systems: getModuleSystems(moduleId, db),
|
|
142
160
|
},
|
|
143
161
|
);
|
|
162
|
+
|
|
163
|
+
// A fully successful refresh stamps the ledger so `celilo dns
|
|
164
|
+
// registrations` shows when each provider last re-asserted.
|
|
165
|
+
if (hookName === 'refresh_registrations' && result.success) {
|
|
166
|
+
stampDnsRegistrationsRefreshed(db, moduleId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return result;
|
|
144
170
|
}
|
package/src/hooks/types.ts
CHANGED
|
@@ -178,6 +178,22 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
178
178
|
inputs: {},
|
|
179
179
|
outputs: {},
|
|
180
180
|
},
|
|
181
|
+
/**
|
|
182
|
+
* Periodic re-assertion of a dns_registrar provider's registered
|
|
183
|
+
* records (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). The
|
|
184
|
+
* framework populates `registrations` from the dns_registrations
|
|
185
|
+
* ledger (module scripts cannot read the celilo DB); the hook
|
|
186
|
+
* re-sends each {fqdn, ip} to the underlying DNS API and fails —
|
|
187
|
+
* loudly — if ANY record cannot be re-asserted. Typically driven by
|
|
188
|
+
* a `timer.tick.15m` subscription.
|
|
189
|
+
*/
|
|
190
|
+
refresh_registrations: {
|
|
191
|
+
inputs: {
|
|
192
|
+
/** Array<{ fqdn: string; ip: string | null }>, framework-injected. */
|
|
193
|
+
registrations: { required: true },
|
|
194
|
+
},
|
|
195
|
+
outputs: {},
|
|
196
|
+
},
|
|
181
197
|
/**
|
|
182
198
|
* Build-bus upstream publish hook. The executor passes the
|
|
183
199
|
* PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
|
package/src/manifest/schema.ts
CHANGED
|
@@ -523,6 +523,16 @@ export const ModuleManifestSchema = z
|
|
|
523
523
|
* [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
|
|
524
524
|
*/
|
|
525
525
|
reconcile_routes: LifecycleHookSchema.optional(),
|
|
526
|
+
/**
|
|
527
|
+
* Periodic re-assertion of a dns_registrar provider's registered
|
|
528
|
+
* records. The framework injects the provider's dns_registrations
|
|
529
|
+
* ledger rows as the `registrations` input; the hook re-sends each
|
|
530
|
+
* one to the underlying DNS API and fails loudly if any cannot be
|
|
531
|
+
* re-asserted. Providers subscribe it to a `timer.tick.*` event.
|
|
532
|
+
* Part of the dns_registrar capability contract — see
|
|
533
|
+
* designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
|
|
534
|
+
*/
|
|
535
|
+
refresh_registrations: LifecycleHookSchema.optional(),
|
|
526
536
|
/**
|
|
527
537
|
* Build-bus upstream publish hooks. Array (a module can react
|
|
528
538
|
* to multiple upstream packages with different actions). See
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
|
|
18
|
-
import { and, eq } from 'drizzle-orm';
|
|
18
|
+
import { and, eq, inArray, or } from 'drizzle-orm';
|
|
19
19
|
import type { DbClient } from '../db/client';
|
|
20
20
|
import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
|
|
21
21
|
import { loadCapabilityFunctions, resolveFirewallNatIp } from '../hooks/capability-loader';
|
|
@@ -54,7 +54,19 @@ export async function backfillProviderDns(
|
|
|
54
54
|
): Promise<void> {
|
|
55
55
|
logger.info(`dns_internal provider '${moduleId}' deployed — backfilling DNS for all systems`);
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// ISS-0009: register only DEPLOYED systems, never IMPORTED-but-undeployed
|
|
58
|
+
// modules (those have a hostname/target_ip configured but nothing serving at
|
|
59
|
+
// that address — registering an A record for them is a phantom record). A
|
|
60
|
+
// deployed system is INSTALLED or VERIFIED. The provider itself is already
|
|
61
|
+
// INSTALLED by the time backfill runs (module-deploy transitions state before
|
|
62
|
+
// calling this), but we union its id explicitly so the provider's own
|
|
63
|
+
// self-record is registered even if that transition order ever changes —
|
|
64
|
+
// critical in daemonless contexts where no dispatcher delivers system.created.
|
|
65
|
+
const deployed = db
|
|
66
|
+
.select()
|
|
67
|
+
.from(modules)
|
|
68
|
+
.where(or(inArray(modules.state, ['INSTALLED', 'VERIFIED']), eq(modules.id, moduleId)))
|
|
69
|
+
.all();
|
|
58
70
|
const failures: string[] = [];
|
|
59
71
|
|
|
60
72
|
// One register per deployed system across all modules — a module with N
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the DNS registration ledger
|
|
3
|
+
* (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2): upsert semantics,
|
|
4
|
+
* the registerHost recording wrapper (success-only), refresh stamping,
|
|
5
|
+
* and FK-cascade cleanup when a module is removed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { DnsRegistrarCapability } from '@celilo/capabilities';
|
|
13
|
+
import { eq } from 'drizzle-orm';
|
|
14
|
+
import { type DbClient, getDb } from '../db/client';
|
|
15
|
+
import { modules } from '../db/schema';
|
|
16
|
+
import {
|
|
17
|
+
listDnsRegistrations,
|
|
18
|
+
recordDnsRegistration,
|
|
19
|
+
stampDnsRegistrationsRefreshed,
|
|
20
|
+
withDnsRegistrationLedger,
|
|
21
|
+
} from './dns-registrations';
|
|
22
|
+
|
|
23
|
+
describe('dns_registrations ledger', () => {
|
|
24
|
+
let tempDir: string;
|
|
25
|
+
let db: DbClient;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsreg-'));
|
|
29
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
30
|
+
db = getDb();
|
|
31
|
+
for (const id of ['namecheap', 'caddy']) {
|
|
32
|
+
db.insert(modules)
|
|
33
|
+
.values({
|
|
34
|
+
id,
|
|
35
|
+
name: id,
|
|
36
|
+
sourcePath: join(tempDir, id),
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
manifestData: { celilo_contract: '1.0', id, name: id, version: '1.0.0' },
|
|
39
|
+
})
|
|
40
|
+
.run();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
46
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('record + list roundtrip; upsert replaces ip and consumer', () => {
|
|
50
|
+
recordDnsRegistration(db, {
|
|
51
|
+
providerModuleId: 'namecheap',
|
|
52
|
+
consumerModuleId: 'caddy',
|
|
53
|
+
fqdn: 'www.lunacycle.net',
|
|
54
|
+
ip: '198.51.100.7',
|
|
55
|
+
});
|
|
56
|
+
recordDnsRegistration(db, {
|
|
57
|
+
providerModuleId: 'namecheap',
|
|
58
|
+
consumerModuleId: 'caddy',
|
|
59
|
+
fqdn: 'www.lunacycle.net',
|
|
60
|
+
ip: '198.51.100.8',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const rows = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
|
|
64
|
+
expect(rows.length).toBe(1);
|
|
65
|
+
expect(rows[0].fqdn).toBe('www.lunacycle.net');
|
|
66
|
+
expect(rows[0].ip).toBe('198.51.100.8');
|
|
67
|
+
expect(rows[0].consumerModuleId).toBe('caddy');
|
|
68
|
+
expect(rows[0].refreshedAt).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('stampDnsRegistrationsRefreshed sets refreshedAt for the provider', () => {
|
|
72
|
+
recordDnsRegistration(db, {
|
|
73
|
+
providerModuleId: 'namecheap',
|
|
74
|
+
consumerModuleId: 'caddy',
|
|
75
|
+
fqdn: 'git.lunacycle.net',
|
|
76
|
+
ip: null,
|
|
77
|
+
});
|
|
78
|
+
stampDnsRegistrationsRefreshed(db, 'namecheap');
|
|
79
|
+
const [row] = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
|
|
80
|
+
expect(row.refreshedAt).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rows die with the consumer module (FK cascade)', () => {
|
|
84
|
+
recordDnsRegistration(db, {
|
|
85
|
+
providerModuleId: 'namecheap',
|
|
86
|
+
consumerModuleId: 'caddy',
|
|
87
|
+
fqdn: 'www.lunacycle.net',
|
|
88
|
+
ip: '198.51.100.7',
|
|
89
|
+
});
|
|
90
|
+
db.delete(modules).where(eq(modules.id, 'caddy')).run();
|
|
91
|
+
expect(listDnsRegistrations(db).length).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('withDnsRegistrationLedger records successes only', async () => {
|
|
95
|
+
const calls: string[] = [];
|
|
96
|
+
const fake: DnsRegistrarCapability = {
|
|
97
|
+
async registerHost(request) {
|
|
98
|
+
calls.push(request.fqdn);
|
|
99
|
+
const success = !request.fqdn.startsWith('fail.');
|
|
100
|
+
return { success, outputs: {}, duration: 1 };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const wrapped = withDnsRegistrationLedger(fake, {
|
|
104
|
+
db,
|
|
105
|
+
providerModuleId: 'namecheap',
|
|
106
|
+
consumerModuleId: 'caddy',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await wrapped.registerHost({ fqdn: 'www.lunacycle.net', ip: '198.51.100.7' });
|
|
110
|
+
await wrapped.registerHost({ fqdn: 'fail.lunacycle.net', ip: '198.51.100.7' });
|
|
111
|
+
await wrapped.registerHost({ fqdn: 'auto.lunacycle.net' });
|
|
112
|
+
|
|
113
|
+
expect(calls.length).toBe(3);
|
|
114
|
+
const rows = listDnsRegistrations(db);
|
|
115
|
+
const fqdns = rows.map((r) => r.fqdn).sort();
|
|
116
|
+
expect(fqdns).toEqual(['auto.lunacycle.net', 'www.lunacycle.net']);
|
|
117
|
+
const auto = rows.find((r) => r.fqdn === 'auto.lunacycle.net');
|
|
118
|
+
expect(auto?.ip).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS registration ledger operations
|
|
3
|
+
* (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2/B3).
|
|
4
|
+
*
|
|
5
|
+
* The capability loader records every successful
|
|
6
|
+
* dns_registrar.registerHost here; the run-hook path reads the ledger
|
|
7
|
+
* back to feed a provider's `refresh_registrations` hook, and `celilo
|
|
8
|
+
* dns registrations` lists it for the operator. Row lifecycle is FK
|
|
9
|
+
* cascade — registrations die with their provider or consumer module.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { DnsRegistrarCapability, HookResult } from '@celilo/capabilities';
|
|
13
|
+
import { eq } from 'drizzle-orm';
|
|
14
|
+
import type { DbClient } from '../db/client';
|
|
15
|
+
import { dnsRegistrations } from '../db/schema';
|
|
16
|
+
|
|
17
|
+
export interface DnsRegistrationRow {
|
|
18
|
+
fqdn: string;
|
|
19
|
+
/** null = the provider auto-detected the request's source IP. */
|
|
20
|
+
ip: string | null;
|
|
21
|
+
providerModuleId: string;
|
|
22
|
+
consumerModuleId: string;
|
|
23
|
+
registeredAt: Date;
|
|
24
|
+
refreshedAt: Date | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function listDnsRegistrations(
|
|
28
|
+
db: DbClient,
|
|
29
|
+
options: { providerModuleId?: string } = {},
|
|
30
|
+
): DnsRegistrationRow[] {
|
|
31
|
+
const query = db
|
|
32
|
+
.select({
|
|
33
|
+
fqdn: dnsRegistrations.fqdn,
|
|
34
|
+
ip: dnsRegistrations.ip,
|
|
35
|
+
providerModuleId: dnsRegistrations.providerModuleId,
|
|
36
|
+
consumerModuleId: dnsRegistrations.consumerModuleId,
|
|
37
|
+
registeredAt: dnsRegistrations.registeredAt,
|
|
38
|
+
refreshedAt: dnsRegistrations.refreshedAt,
|
|
39
|
+
})
|
|
40
|
+
.from(dnsRegistrations);
|
|
41
|
+
const rows = options.providerModuleId
|
|
42
|
+
? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
|
|
43
|
+
: query.all();
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function recordDnsRegistration(
|
|
48
|
+
db: DbClient,
|
|
49
|
+
registration: {
|
|
50
|
+
providerModuleId: string;
|
|
51
|
+
consumerModuleId: string;
|
|
52
|
+
fqdn: string;
|
|
53
|
+
ip: string | null;
|
|
54
|
+
},
|
|
55
|
+
): void {
|
|
56
|
+
db.insert(dnsRegistrations)
|
|
57
|
+
.values({
|
|
58
|
+
providerModuleId: registration.providerModuleId,
|
|
59
|
+
consumerModuleId: registration.consumerModuleId,
|
|
60
|
+
fqdn: registration.fqdn,
|
|
61
|
+
ip: registration.ip,
|
|
62
|
+
registeredAt: new Date(),
|
|
63
|
+
})
|
|
64
|
+
.onConflictDoUpdate({
|
|
65
|
+
target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
|
|
66
|
+
set: {
|
|
67
|
+
consumerModuleId: registration.consumerModuleId,
|
|
68
|
+
ip: registration.ip,
|
|
69
|
+
registeredAt: new Date(),
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
.run();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stamp every row of a provider as refreshed (successful refresh hook). */
|
|
76
|
+
export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: string): void {
|
|
77
|
+
db.update(dnsRegistrations)
|
|
78
|
+
.set({ refreshedAt: new Date() })
|
|
79
|
+
.where(eq(dnsRegistrations.providerModuleId, providerModuleId))
|
|
80
|
+
.run();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wrap a dns_registrar interface so successful registerHost calls are
|
|
85
|
+
* recorded in the ledger. Failures and MissingProviderInputError
|
|
86
|
+
* interview throws pass through untouched — only a confirmed
|
|
87
|
+
* registration earns a row.
|
|
88
|
+
*/
|
|
89
|
+
export function withDnsRegistrationLedger(
|
|
90
|
+
registrar: DnsRegistrarCapability,
|
|
91
|
+
ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
|
|
92
|
+
): DnsRegistrarCapability {
|
|
93
|
+
return {
|
|
94
|
+
...registrar,
|
|
95
|
+
async registerHost(request): Promise<HookResult> {
|
|
96
|
+
const result = await registrar.registerHost(request);
|
|
97
|
+
if (result.success && request.fqdn) {
|
|
98
|
+
recordDnsRegistration(ctx.db, {
|
|
99
|
+
providerModuleId: ctx.providerModuleId,
|
|
100
|
+
consumerModuleId: ctx.consumerModuleId,
|
|
101
|
+
fqdn: request.fqdn,
|
|
102
|
+
ip: request.ip ?? null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|