@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.
Files changed (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. 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: '71.36.99.96',
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('71.36.99.96');
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('71.36.99.96');
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] = wrapWithLogging(capabilityInterface as object, logger, 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
- return invokeHook(
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
- options.inputs ?? {},
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
  }
@@ -119,7 +119,8 @@ export type HookName =
119
119
  | 'on_backup'
120
120
  | 'on_backup_analyze'
121
121
  | 'on_restore'
122
- | 'on_system_event';
122
+ | 'on_system_event'
123
+ | 'refresh_registrations';
123
124
 
124
125
  /**
125
126
  * Hook manifest section - maps hook names to definitions
@@ -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,
@@ -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: 'lunacycle.net' },
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('lunacycle.net');
61
+ expect(reply.value).toBe('example.net');
62
62
 
63
63
  watch.close();
64
64
  responderBus.close();