@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.3
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/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
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readInstalledUnit,
|
|
9
9
|
renderLaunchdPlist,
|
|
10
10
|
renderSystemdUnit,
|
|
11
|
+
resolveRunAsUser,
|
|
11
12
|
uninstallDaemon,
|
|
12
13
|
} from './events-daemon';
|
|
13
14
|
|
|
@@ -19,6 +20,7 @@ describe('renderSystemdUnit', () => {
|
|
|
19
20
|
pollMs: 1000,
|
|
20
21
|
concurrency: 4,
|
|
21
22
|
home: '/home/op',
|
|
23
|
+
scope: 'user',
|
|
22
24
|
});
|
|
23
25
|
expect(out).toContain(
|
|
24
26
|
'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
|
|
@@ -26,6 +28,7 @@ describe('renderSystemdUnit', () => {
|
|
|
26
28
|
expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
|
|
27
29
|
expect(out).toContain('Restart=on-failure');
|
|
28
30
|
expect(out).toContain('WantedBy=default.target');
|
|
31
|
+
expect(out).not.toContain('User=');
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
it('honors --poll-ms and --concurrency overrides', () => {
|
|
@@ -35,9 +38,26 @@ describe('renderSystemdUnit', () => {
|
|
|
35
38
|
pollMs: 250,
|
|
36
39
|
concurrency: 8,
|
|
37
40
|
home: '/h',
|
|
41
|
+
scope: 'user',
|
|
38
42
|
});
|
|
39
43
|
expect(out).toContain('--poll-ms 250 --concurrency 8');
|
|
40
44
|
});
|
|
45
|
+
|
|
46
|
+
it('system scope sets explicit User= and multi-user.target', () => {
|
|
47
|
+
const out = renderSystemdUnit({
|
|
48
|
+
celiloPath: '/usr/local/bin/celilo',
|
|
49
|
+
busDbPath: '/var/celilo/events.db',
|
|
50
|
+
pollMs: 1000,
|
|
51
|
+
concurrency: 4,
|
|
52
|
+
home: '/root',
|
|
53
|
+
scope: 'system',
|
|
54
|
+
runAsUser: 'celilo',
|
|
55
|
+
});
|
|
56
|
+
expect(out).toContain('User=celilo');
|
|
57
|
+
expect(out).toContain('WantedBy=multi-user.target');
|
|
58
|
+
expect(out).toContain('journalctl -u celilo-events.service');
|
|
59
|
+
expect(out).not.toContain('journalctl --user');
|
|
60
|
+
});
|
|
41
61
|
});
|
|
42
62
|
|
|
43
63
|
describe('renderLaunchdPlist', () => {
|
|
@@ -48,6 +68,7 @@ describe('renderLaunchdPlist', () => {
|
|
|
48
68
|
pollMs: 1000,
|
|
49
69
|
concurrency: 4,
|
|
50
70
|
home: '/Users/op',
|
|
71
|
+
scope: 'user',
|
|
51
72
|
});
|
|
52
73
|
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
|
|
53
74
|
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
|
|
@@ -55,6 +76,24 @@ describe('renderLaunchdPlist', () => {
|
|
|
55
76
|
expect(out).toContain('<string>com.celilo.events</string>');
|
|
56
77
|
expect(out).toContain('<key>RunAtLoad</key>');
|
|
57
78
|
expect(out).toContain('<key>KeepAlive</key>');
|
|
79
|
+
expect(out).not.toContain('<key>UserName</key>');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('system scope sets UserName and logs under /Library/Logs', () => {
|
|
83
|
+
const out = renderLaunchdPlist({
|
|
84
|
+
celiloPath: '/c',
|
|
85
|
+
busDbPath: '/db',
|
|
86
|
+
pollMs: 1000,
|
|
87
|
+
concurrency: 4,
|
|
88
|
+
home: '/Users/op',
|
|
89
|
+
scope: 'system',
|
|
90
|
+
runAsUser: 'celilo',
|
|
91
|
+
});
|
|
92
|
+
expect(out).toContain('<key>UserName</key>');
|
|
93
|
+
expect(out).toContain('<string>celilo</string>');
|
|
94
|
+
expect(out).toContain('<string>/Library/Logs/celilo-events.out.log</string>');
|
|
95
|
+
expect(out).toContain('<string>/Library/Logs/celilo-events.err.log</string>');
|
|
96
|
+
expect(out).not.toContain('/Users/op/Library/Logs');
|
|
58
97
|
});
|
|
59
98
|
});
|
|
60
99
|
|
|
@@ -69,6 +108,24 @@ describe('getDaemonUnitPath', () => {
|
|
|
69
108
|
'/Users/op/Library/LaunchAgents/com.celilo.events.plist',
|
|
70
109
|
);
|
|
71
110
|
});
|
|
111
|
+
it('returns the system paths for system scope', () => {
|
|
112
|
+
expect(getDaemonUnitPath('linux', '/home/op', 'system')).toBe(
|
|
113
|
+
'/etc/systemd/system/celilo-events.service',
|
|
114
|
+
);
|
|
115
|
+
expect(getDaemonUnitPath('darwin', '/Users/op', 'system')).toBe(
|
|
116
|
+
'/Library/LaunchDaemons/com.celilo.events.plist',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('resolveRunAsUser', () => {
|
|
122
|
+
it('honors an explicit override', () => {
|
|
123
|
+
expect(resolveRunAsUser('/var/celilo/events.db', 'celilo')).toBe('celilo');
|
|
124
|
+
});
|
|
125
|
+
it('falls back to the current user when the state dir is missing', () => {
|
|
126
|
+
const me = resolveRunAsUser('/no/such/dir/events.db');
|
|
127
|
+
expect(me.length).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
72
129
|
});
|
|
73
130
|
|
|
74
131
|
describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
@@ -99,6 +156,8 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
|
99
156
|
busDbPath: '/var/lib/celilo/events.db',
|
|
100
157
|
});
|
|
101
158
|
expect(existsSync(installed.unitPath)).toBe(true);
|
|
159
|
+
expect(installed.scope).toBe('user');
|
|
160
|
+
expect(installed.runAsUser).toBeUndefined();
|
|
102
161
|
expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service'));
|
|
103
162
|
expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload');
|
|
104
163
|
|