@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11
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/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +211 -1
- package/src/api-clients/proxmox.ts +399 -8
- package/src/cli/command-registry.ts +83 -6
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/proxmox-vm-template-build.ts +171 -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/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- 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 +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- 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 +54 -4
- package/src/services/deploy-validation.ts +27 -36
- 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-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/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/services/module-validator/capability-versions.test.ts +1 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +92 -12
- package/src/templates/generator.ts +165 -80
- 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
|
@@ -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
|
@@ -432,6 +432,80 @@ 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
|
+
|
|
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
|
+
|
|
435
509
|
/**
|
|
436
510
|
* Backup storage providers - destinations for backup archives
|
|
437
511
|
* Supports local filesystem and S3-compatible storage (AWS S3, MinIO, Backblaze B2, Wasabi)
|
|
@@ -37,7 +37,7 @@ export default defineCapabilityFunction({
|
|
|
37
37
|
capability: 'firewall',
|
|
38
38
|
handler: ({ config, secrets }) => ({
|
|
39
39
|
exposeService: async (opts) => ({
|
|
40
|
-
externalIp: '
|
|
40
|
+
externalIp: '203.0.113.10',
|
|
41
41
|
natIp: opts.internalIp,
|
|
42
42
|
}),
|
|
43
43
|
unexposeService: async () => {},
|
|
@@ -135,7 +135,7 @@ describe('Firewall Chain Building', () => {
|
|
|
135
135
|
ports: [80],
|
|
136
136
|
description: 'test',
|
|
137
137
|
});
|
|
138
|
-
expect(exposed.externalIp).toBe('
|
|
138
|
+
expect(exposed.externalIp).toBe('203.0.113.10');
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
test('builds chain with two providers (iptables → greenwave)', async () => {
|
|
@@ -206,7 +206,7 @@ describe('Firewall Chain Building', () => {
|
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
// External IP came from greenwave (leaf)
|
|
209
|
-
expect(exposed.externalIp).toBe('
|
|
209
|
+
expect(exposed.externalIp).toBe('203.0.113.10');
|
|
210
210
|
// NAT IP is iptables' NAT address
|
|
211
211
|
expect(exposed.natIp).toBe('192.168.0.253');
|
|
212
212
|
// iptables created local rules
|
|
@@ -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,8 @@ 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 { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
35
|
+
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
33
36
|
import { loadHookConfigMap } from './load-hook-config';
|
|
34
37
|
|
|
35
38
|
/**
|
|
@@ -60,6 +63,10 @@ const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName:
|
|
|
60
63
|
script: 'scripts/idp-functions.ts',
|
|
61
64
|
legacyFactoryName: 'createIdp',
|
|
62
65
|
},
|
|
66
|
+
git_forge: {
|
|
67
|
+
script: 'scripts/git-forge-functions.ts',
|
|
68
|
+
legacyFactoryName: 'createForgejoGitForge',
|
|
69
|
+
},
|
|
63
70
|
dhcp_server: {
|
|
64
71
|
script: 'scripts/dhcp-server-functions.ts',
|
|
65
72
|
legacyFactoryName: 'default',
|
|
@@ -194,6 +201,28 @@ export async function loadCapabilityFunctions(
|
|
|
194
201
|
const providerConfig = await loadModuleConfig(capability.moduleId, db);
|
|
195
202
|
const providerSecrets = await loadModuleSecrets(capability.moduleId, masterKey, db);
|
|
196
203
|
|
|
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
|
+
};
|
|
225
|
+
|
|
197
226
|
try {
|
|
198
227
|
// Dynamically import the capability module. Try the default export
|
|
199
228
|
// first (the HOOK_API_V2 Phase 8 pattern), fall back to the legacy
|
|
@@ -217,7 +246,7 @@ export async function loadCapabilityFunctions(
|
|
|
217
246
|
systems: getModuleSystems(capability.moduleId, db),
|
|
218
247
|
logger,
|
|
219
248
|
});
|
|
220
|
-
result[capName] = capabilityInterface;
|
|
249
|
+
result[capName] = withLedger(capabilityInterface);
|
|
221
250
|
debugLog(`${capName}: loaded via defineCapabilityFunction`);
|
|
222
251
|
continue;
|
|
223
252
|
}
|
|
@@ -233,7 +262,9 @@ export async function loadCapabilityFunctions(
|
|
|
233
262
|
);
|
|
234
263
|
|
|
235
264
|
if (capabilityInterface) {
|
|
236
|
-
result[capName] =
|
|
265
|
+
result[capName] = withLedger(
|
|
266
|
+
wrapWithLogging(capabilityInterface as object, logger, capName),
|
|
267
|
+
);
|
|
237
268
|
debugLog(`${capName}: loaded via legacy factory`);
|
|
238
269
|
}
|
|
239
270
|
} catch (error) {
|
|
@@ -388,6 +419,16 @@ export async function loadCapabilityFunctions(
|
|
|
388
419
|
`public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
|
|
389
420
|
);
|
|
390
421
|
}
|
|
422
|
+
// ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
|
|
423
|
+
// providers reconciled it (no subscriber consumed routes_changed). That
|
|
424
|
+
// registers a route nobody applied and would otherwise report success.
|
|
425
|
+
// `events === 0` means no routes changed (benign); `events > 0 &&
|
|
426
|
+
// succeeded === 0` means the route changed but nobody served it — a failure.
|
|
427
|
+
if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`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.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
391
432
|
},
|
|
392
433
|
});
|
|
393
434
|
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
|
@@ -78,6 +78,21 @@ describe('extractProxmoxProperties', () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test('omits vm_template when the service config has none (LXC services)', () => {
|
|
82
|
+
const properties = extractProxmoxProperties(100, '10.0.10.5', 'caddy', mockProxmoxConfig);
|
|
83
|
+
// Omitted, not empty — so a required-infrastructure `vm_template` var fails
|
|
84
|
+
// loudly rather than resolving to "".
|
|
85
|
+
expect('vm_template' in properties).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('includes vm_template when the service config provides one (VM services)', () => {
|
|
89
|
+
const properties = extractProxmoxProperties(100, '10.0.10.5', 'builder', {
|
|
90
|
+
...mockProxmoxConfig,
|
|
91
|
+
vm_template: 'ubuntu-2204-cloudinit',
|
|
92
|
+
});
|
|
93
|
+
expect(properties.vm_template).toBe('ubuntu-2204-cloudinit');
|
|
94
|
+
});
|
|
95
|
+
|
|
81
96
|
test('converts vmid number to string', () => {
|
|
82
97
|
const properties = extractProxmoxProperties(12345, '10.0.20.15', 'caddy', mockProxmoxConfig);
|
|
83
98
|
|
|
@@ -47,6 +47,13 @@ export interface ProxmoxProviderConfig {
|
|
|
47
47
|
default_target_node: string;
|
|
48
48
|
lxc_template: string;
|
|
49
49
|
storage: string;
|
|
50
|
+
/**
|
|
51
|
+
* Cloud-init VM template to clone for `requires.system.type: vm` modules — the
|
|
52
|
+
* VM analogue of `lxc_template`. Optional: only Proxmox services that host VM
|
|
53
|
+
* modules configure it. A VM module declares `vm_template` as a *required*
|
|
54
|
+
* infrastructure var, so a service missing it fails loudly at resolution.
|
|
55
|
+
*/
|
|
56
|
+
vm_template?: string;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/**
|
|
@@ -72,6 +79,11 @@ export function extractProxmoxProperties(
|
|
|
72
79
|
target_node: providerConfig.default_target_node,
|
|
73
80
|
lxc_template: providerConfig.lxc_template,
|
|
74
81
|
storage: providerConfig.storage,
|
|
82
|
+
// VM clone source — present only when the service configures it. VM modules
|
|
83
|
+
// declare `vm_template` as a required infrastructure var (resolution errors
|
|
84
|
+
// if absent); LXC modules never reference it. Omitted (not empty) when unset
|
|
85
|
+
// so the resolver's required/optional handling stays correct.
|
|
86
|
+
...(providerConfig.vm_template ? { vm_template: providerConfig.vm_template } : {}),
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
|
|
@@ -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
|
@@ -311,6 +311,13 @@ export const SystemResourceSchema = z.object({
|
|
|
311
311
|
memory: z.number().int().positive().optional().describe('Recommended memory in MB'),
|
|
312
312
|
disk: z.number().int().positive().optional().describe('Recommended disk size in GB'),
|
|
313
313
|
storage: z.string().optional().describe('Proxmox storage backend (defaults to system config)'),
|
|
314
|
+
type: z
|
|
315
|
+
.enum(['lxc', 'vm'])
|
|
316
|
+
.default('lxc')
|
|
317
|
+
.describe(
|
|
318
|
+
'Proxmox provisioning type: lxc (default) or vm (qemu, for Docker / kernel-module workloads). ' +
|
|
319
|
+
'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.',
|
|
320
|
+
),
|
|
314
321
|
zone: z
|
|
315
322
|
.enum(['internal', 'dmz', 'app', 'secure', 'external'])
|
|
316
323
|
.describe('Required security zone for this module'),
|
|
@@ -523,6 +530,16 @@ export const ModuleManifestSchema = z
|
|
|
523
530
|
* [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
|
|
524
531
|
*/
|
|
525
532
|
reconcile_routes: LifecycleHookSchema.optional(),
|
|
533
|
+
/**
|
|
534
|
+
* Periodic re-assertion of a dns_registrar provider's registered
|
|
535
|
+
* records. The framework injects the provider's dns_registrations
|
|
536
|
+
* ledger rows as the `registrations` input; the hook re-sends each
|
|
537
|
+
* one to the underlying DNS API and fails loudly if any cannot be
|
|
538
|
+
* re-asserted. Providers subscribe it to a `timer.tick.*` event.
|
|
539
|
+
* Part of the dns_registrar capability contract — see
|
|
540
|
+
* designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
|
|
541
|
+
*/
|
|
542
|
+
refresh_registrations: LifecycleHookSchema.optional(),
|
|
526
543
|
/**
|
|
527
544
|
* Build-bus upstream publish hooks. Array (a module can react
|
|
528
545
|
* to multiple upstream packages with different actions). See
|
|
@@ -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();
|