@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.0

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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,235 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync } from 'node:fs';
3
+ import { rm } from 'node:fs/promises';
4
+ import { type DbClient, createDbClient } from '../db/client';
5
+ import {
6
+ containerServices,
7
+ machines,
8
+ moduleConfigs,
9
+ moduleInfrastructure,
10
+ moduleSystems,
11
+ modules,
12
+ } from '../db/schema';
13
+ import { backfillModuleSystems, getModuleSystems } from './deployed-systems';
14
+
15
+ const TEST_DB_PATH = './test-deployed-systems.db';
16
+
17
+ /**
18
+ * Coverage for the one-time `module_systems` upgrade backfill
19
+ * (v2/MODULE_SYSTEMS_ADDRESSING.md): a deployment created before 0007 has its
20
+ * host data in module_configs / ip_allocations / module_infrastructure but an
21
+ * empty module_systems, and the refactored hooks resolve to no system. The
22
+ * backfill reconstructs it. Mirrors the real turnip prod state (caddy etc.:
23
+ * proxmox container_service, target_ip with /24, vmid in module_configs).
24
+ */
25
+ const SERVICE_ID = 'svc-proxmox';
26
+
27
+ function ensureProxmoxService(db: DbClient): void {
28
+ if (
29
+ db
30
+ .select()
31
+ .from(containerServices)
32
+ .all()
33
+ .some((s) => s.id === SERVICE_ID)
34
+ )
35
+ return;
36
+ db.insert(containerServices)
37
+ .values({
38
+ id: SERVICE_ID,
39
+ serviceId: SERVICE_ID,
40
+ name: 'Proxmox',
41
+ providerName: 'proxmox',
42
+ zones: ['dmz', 'app', 'internal'] as Array<
43
+ 'internal' | 'dmz' | 'app' | 'secure' | 'external'
44
+ >,
45
+ apiCredentialsEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
46
+ providerConfig: { default_target_node: 'pve', lxc_template: 't', storage: 's' },
47
+ verified: true,
48
+ })
49
+ .run();
50
+ }
51
+
52
+ function seedProxmoxModule(
53
+ db: DbClient,
54
+ opts: { id: string; hostname: string; zone: string; targetIp: string; vmid: number },
55
+ ): void {
56
+ const serviceId = SERVICE_ID;
57
+ ensureProxmoxService(db);
58
+ db.insert(modules)
59
+ .values({
60
+ id: opts.id,
61
+ name: opts.id,
62
+ version: '1.0.0',
63
+ manifestData: { requires: { machine: { zone: opts.zone } } },
64
+ sourcePath: `/tmp/${opts.id}`,
65
+ state: 'VERIFIED',
66
+ })
67
+ .run();
68
+ db.insert(moduleInfrastructure)
69
+ .values({
70
+ id: `infra-${opts.id}`,
71
+ moduleId: opts.id,
72
+ infrastructureType: 'container_service',
73
+ serviceId,
74
+ })
75
+ .run();
76
+ for (const [key, value] of [
77
+ ['hostname', opts.hostname],
78
+ ['zone', opts.zone],
79
+ ['target_ip', opts.targetIp],
80
+ ['vmid', String(opts.vmid)],
81
+ ]) {
82
+ db.insert(moduleConfigs)
83
+ .values({ moduleId: opts.id, key, value, valueJson: JSON.stringify(value) })
84
+ .run();
85
+ }
86
+ }
87
+
88
+ describe('backfillModuleSystems', () => {
89
+ let db: DbClient;
90
+
91
+ beforeEach(() => {
92
+ db = createDbClient({ path: TEST_DB_PATH });
93
+ });
94
+
95
+ afterEach(async () => {
96
+ db.$client.close();
97
+ for (const suffix of ['', '-shm', '-wal']) {
98
+ const p = `${TEST_DB_PATH}${suffix}`;
99
+ if (existsSync(p)) await rm(p);
100
+ }
101
+ });
102
+
103
+ test('reconstructs a pre-0007 proxmox deployment into module_systems', () => {
104
+ seedProxmoxModule(db, {
105
+ id: 'caddy',
106
+ hostname: 'www',
107
+ zone: 'dmz',
108
+ targetIp: '10.0.10.10/24',
109
+ vmid: 200,
110
+ });
111
+ expect(getModuleSystems('caddy', db)).toHaveLength(0);
112
+
113
+ const filled = backfillModuleSystems(db);
114
+ expect(filled).toEqual(['caddy']);
115
+
116
+ const systems = getModuleSystems('caddy', db);
117
+ expect(systems).toHaveLength(1);
118
+ expect(systems[0]).toMatchObject({
119
+ name: 'main',
120
+ hostname: 'www',
121
+ ipv4_address: '10.0.10.10', // CIDR stripped
122
+ zone: 'dmz',
123
+ infrastructure: { type: 'container_service', serviceId: 'svc-proxmox', vmid: 200 },
124
+ });
125
+ });
126
+
127
+ test('is idempotent — a second run backfills nothing', () => {
128
+ seedProxmoxModule(db, {
129
+ id: 'authentik',
130
+ hostname: 'authentik',
131
+ zone: 'app',
132
+ targetIp: '10.0.20.10/24',
133
+ vmid: 201,
134
+ });
135
+ expect(backfillModuleSystems(db)).toEqual(['authentik']);
136
+ expect(backfillModuleSystems(db)).toEqual([]);
137
+ expect(getModuleSystems('authentik', db)).toHaveLength(1);
138
+ });
139
+
140
+ test('does not overwrite a system already recorded post-refactor', () => {
141
+ seedProxmoxModule(db, {
142
+ id: 'lunacycle',
143
+ hostname: 'lunacycle',
144
+ zone: 'app',
145
+ targetIp: '10.0.20.11/24',
146
+ vmid: 202,
147
+ });
148
+ // Simulate a deploy-time recording already present with a different IP.
149
+ db.insert(moduleSystems)
150
+ .values({
151
+ moduleId: 'lunacycle',
152
+ name: 'main',
153
+ hostname: 'lunacycle',
154
+ ipv4Address: '10.0.20.99',
155
+ zone: 'app',
156
+ infraType: 'container_service',
157
+ serviceId: 'svc-proxmox',
158
+ vmid: 202,
159
+ })
160
+ .run();
161
+
162
+ expect(backfillModuleSystems(db)).toEqual([]);
163
+ expect(getModuleSystems('lunacycle', db)[0].ipv4_address).toBe('10.0.20.99');
164
+ });
165
+
166
+ test('backfills a machine-pool deployment from the machine IP', () => {
167
+ db.insert(machines)
168
+ .values({
169
+ id: 'm-1',
170
+ hostname: 'pi-dns',
171
+ ipAddress: '192.168.0.151',
172
+ sshUser: 'peba',
173
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
174
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
175
+ zone: 'internal',
176
+ })
177
+ .run();
178
+ db.insert(modules)
179
+ .values({
180
+ id: 'technitium',
181
+ name: 'technitium',
182
+ version: '1.0.0',
183
+ manifestData: { requires: { machine: { zone: 'internal' } } },
184
+ sourcePath: '/tmp/technitium',
185
+ state: 'VERIFIED',
186
+ })
187
+ .run();
188
+ db.insert(moduleInfrastructure)
189
+ .values({
190
+ id: 'infra-technitium',
191
+ moduleId: 'technitium',
192
+ infrastructureType: 'machine',
193
+ machineId: 'm-1',
194
+ })
195
+ .run();
196
+ db.insert(moduleConfigs)
197
+ .values({ moduleId: 'technitium', key: 'hostname', value: 'dns-int', valueJson: '"dns-int"' })
198
+ .run();
199
+
200
+ expect(backfillModuleSystems(db)).toEqual(['technitium']);
201
+ const systems = getModuleSystems('technitium', db);
202
+ expect(systems[0]).toMatchObject({
203
+ hostname: 'dns-int',
204
+ ipv4_address: '192.168.0.151',
205
+ zone: 'internal',
206
+ infrastructure: { type: 'machine', machineId: 'm-1' },
207
+ });
208
+ expect(systems[0].infrastructure.vmid).toBeUndefined();
209
+ });
210
+
211
+ test('skips an API-only module (no declared systems)', () => {
212
+ ensureProxmoxService(db);
213
+ db.insert(modules)
214
+ .values({
215
+ id: 'namecheap',
216
+ name: 'namecheap',
217
+ version: '1.0.0',
218
+ manifestData: { requires: {} },
219
+ sourcePath: '/tmp/namecheap',
220
+ state: 'INSTALLED',
221
+ })
222
+ .run();
223
+ db.insert(moduleInfrastructure)
224
+ .values({
225
+ id: 'infra-namecheap',
226
+ moduleId: 'namecheap',
227
+ infrastructureType: 'container_service',
228
+ serviceId: 'svc-proxmox',
229
+ })
230
+ .run();
231
+
232
+ expect(backfillModuleSystems(db)).toEqual([]);
233
+ expect(getModuleSystems('namecheap', db)).toHaveLength(0);
234
+ });
235
+ });
@@ -0,0 +1,308 @@
1
+ import type { DeployedSystem } from '@celilo/capabilities';
2
+ import { and, eq } from 'drizzle-orm';
3
+ import type { DbClient } from '../db/client';
4
+ import {
5
+ type NetworkZone,
6
+ machines,
7
+ moduleConfigs,
8
+ moduleInfrastructure,
9
+ moduleSystems,
10
+ modules,
11
+ } from '../db/schema';
12
+ import { type ModuleManifest, getDeclaredSystems } from '../manifest/schema';
13
+ import type { InfrastructureSelection } from '../types/infrastructure';
14
+ import type { InfraSystemFields } from '../variables/types';
15
+
16
+ /**
17
+ * The deployment-STATE layer: a module's 0..N deployed systems
18
+ * (v2/MODULE_SYSTEMS_ADDRESSING.md). Replaces the scalar `target_ip`/`vmid`
19
+ * rows that used to live in module_configs and the single-result
20
+ * `getModuleHostAndIp`. There is deliberately no "get THE system" helper —
21
+ * callers work with the array so the 0/1/N reality stays visible.
22
+ */
23
+
24
+ /** Strip CIDR notation from an IPv4 address ("10.0.20.10/24" → "10.0.20.10"). */
25
+ function stripCidr(addr: string): string {
26
+ const slash = addr.indexOf('/');
27
+ return slash === -1 ? addr : addr.slice(0, slash);
28
+ }
29
+
30
+ function rowToSystem(row: typeof moduleSystems.$inferSelect): DeployedSystem {
31
+ return {
32
+ name: row.name,
33
+ hostname: row.hostname,
34
+ ipv4_address: row.ipv4Address,
35
+ zone: row.zone,
36
+ infrastructure: {
37
+ type: row.infraType,
38
+ ...(row.machineId ? { machineId: row.machineId } : {}),
39
+ ...(row.serviceId ? { serviceId: row.serviceId } : {}),
40
+ ...(row.vmid != null ? { vmid: row.vmid } : {}),
41
+ },
42
+ };
43
+ }
44
+
45
+ /**
46
+ * All systems a module has deployed onto, ordered by name for determinism.
47
+ * Returns [] for API-only modules (e.g. namecheap) — that is a modeled state,
48
+ * not an error.
49
+ */
50
+ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem[] {
51
+ const rows = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all();
52
+ return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
53
+ }
54
+
55
+ /** Input to {@link upsertDeployedSystem} — the realized facts about one host. */
56
+ export interface DeployedSystemInput {
57
+ /** Stable handle from requires.systems[].name — the per-system key. */
58
+ name: string;
59
+ hostname: string;
60
+ /** Accepts CIDR or bare; stored CIDR-stripped. */
61
+ ipv4Address: string;
62
+ zone: NetworkZone;
63
+ infraType: 'machine' | 'container_service';
64
+ machineId?: string | null;
65
+ serviceId?: string | null;
66
+ vmid?: number | null;
67
+ }
68
+
69
+ /**
70
+ * Insert or update one deployed system for a module, keyed by (moduleId, name).
71
+ * Idempotent — re-deploying the same system overwrites its address / infra
72
+ * fields. This is the single sink the old `target_ip` write sites collapse into.
73
+ */
74
+ export function upsertDeployedSystem(
75
+ db: DbClient,
76
+ moduleId: string,
77
+ system: DeployedSystemInput,
78
+ ): void {
79
+ const ipv4 = stripCidr(system.ipv4Address);
80
+ db.insert(moduleSystems)
81
+ .values({
82
+ moduleId,
83
+ name: system.name,
84
+ hostname: system.hostname,
85
+ ipv4Address: ipv4,
86
+ zone: system.zone,
87
+ infraType: system.infraType,
88
+ machineId: system.machineId ?? null,
89
+ serviceId: system.serviceId ?? null,
90
+ vmid: system.vmid ?? null,
91
+ updatedAt: new Date(),
92
+ })
93
+ .onConflictDoUpdate({
94
+ target: [moduleSystems.moduleId, moduleSystems.name],
95
+ set: {
96
+ hostname: system.hostname,
97
+ ipv4Address: ipv4,
98
+ zone: system.zone,
99
+ infraType: system.infraType,
100
+ machineId: system.machineId ?? null,
101
+ serviceId: system.serviceId ?? null,
102
+ vmid: system.vmid ?? null,
103
+ updatedAt: new Date(),
104
+ },
105
+ })
106
+ .run();
107
+ }
108
+
109
+ const NETWORK_ZONES: readonly NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'external'];
110
+
111
+ function asZone(value: string | undefined): NetworkZone | null {
112
+ return value && (NETWORK_ZONES as readonly string[]).includes(value)
113
+ ? (value as NetworkZone)
114
+ : null;
115
+ }
116
+
117
+ /**
118
+ * Resolve a module's deployed system(s) from the deploy state and persist them
119
+ * to `module_systems`. Called during deploy after infrastructure variables are
120
+ * resolved (the IP is known for machine / proxmox / DO alike). This is the
121
+ * single sink the old scattered `target_ip` writes collapse into.
122
+ *
123
+ * Transition: every current module declares exactly one system (via
124
+ * `requires.machine`, normalized to name `main`, or one `requires.systems`
125
+ * entry), so this records that single host from `hostname` config + the
126
+ * resolved IP. Returns the systems it recorded ([] for an API-only module with
127
+ * no host — e.g. namecheap). See v2/MODULE_SYSTEMS_ADDRESSING.md.
128
+ */
129
+ export async function recordDeployedSystemForModule(
130
+ moduleId: string,
131
+ manifest: ModuleManifest,
132
+ infrastructure: InfrastructureSelection | undefined,
133
+ db: DbClient,
134
+ ): Promise<DeployedSystem[]> {
135
+ const declared = getDeclaredSystems(manifest);
136
+ // No declared systems → API-only module (e.g. namecheap). Records nothing.
137
+ if (declared.length === 0) return [];
138
+
139
+ const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
140
+ const cfg = (key: string) => configs.find((c) => c.key === key)?.value;
141
+
142
+ const hostname = cfg('hostname');
143
+ // No hostname → not yet addressable. A modeled state, not an error.
144
+ if (!hostname) return [];
145
+
146
+ // IP: the module's resolved target_ip, else ip.primary, else the assigned
147
+ // machine's own IP (machine-pool deploys where neither was written).
148
+ let ip = cfg('target_ip') ?? cfg('ip.primary');
149
+ if (!ip && infrastructure?.machineId) {
150
+ const machine = db
151
+ .select()
152
+ .from(machines)
153
+ .where(eq(machines.id, infrastructure.machineId))
154
+ .get();
155
+ ip = machine?.ipAddress;
156
+ }
157
+ if (!ip) return [];
158
+
159
+ // Single-system transition: take the first declared system's name + zone.
160
+ const decl = declared[0];
161
+ const zone = asZone(decl.resources.zone) ?? asZone(cfg('zone'));
162
+ if (!zone) {
163
+ throw new Error(
164
+ `Cannot record deployed system for '${moduleId}': no resolvable network zone (checked requires.systems[].resources.zone and config.zone).`,
165
+ );
166
+ }
167
+
168
+ const infraType = infrastructure?.type ?? 'machine';
169
+ const vmidStr = cfg('vmid');
170
+ const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null;
171
+
172
+ upsertDeployedSystem(db, moduleId, {
173
+ name: decl.name,
174
+ hostname,
175
+ ipv4Address: ip,
176
+ zone,
177
+ infraType,
178
+ machineId: infrastructure?.machineId ?? null,
179
+ serviceId: infrastructure?.serviceId ?? null,
180
+ vmid: Number.isNaN(vmid as number) ? null : vmid,
181
+ });
182
+
183
+ return getModuleSystems(moduleId, db);
184
+ }
185
+
186
+ /**
187
+ * One-time upgrade backfill: populate `module_systems` for deployments that
188
+ * predate `0007_module_systems` (v2/MODULE_SYSTEMS_ADDRESSING.md). A DB created
189
+ * before the target_ip → systems refactor has its host data in
190
+ * `module_configs.target_ip` / `vmid` / `zone` + `ip_allocations` +
191
+ * `module_infrastructure`, but an empty `module_systems` — so `$infra:` and
192
+ * `ctx.systems` resolve to nothing and the migrated hooks throw "No deployed
193
+ * system found". This reconstructs each module's system from that existing
194
+ * state, mirroring `recordDeployedSystemForModule`'s field derivation.
195
+ *
196
+ * Idempotent: skips any module that already has a `module_systems` row, so it's
197
+ * safe to call on every schema upgrade. Intended to run once, right after
198
+ * migrations apply, gated on a migration actually having happened (a fresh DB
199
+ * has no `module_infrastructure` rows, so this is a no-op there). Returns the
200
+ * module ids it backfilled, for operator-visible logging.
201
+ */
202
+ export function backfillModuleSystems(db: DbClient): string[] {
203
+ const backfilled: string[] = [];
204
+
205
+ for (const infra of db.select().from(moduleInfrastructure).all()) {
206
+ // Already recorded (post-refactor deploy, or a prior backfill run) — skip.
207
+ if (getModuleSystems(infra.moduleId, db).length > 0) continue;
208
+
209
+ const moduleRow = db.select().from(modules).where(eq(modules.id, infra.moduleId)).get();
210
+ if (!moduleRow?.manifestData) continue;
211
+
212
+ const declared = getDeclaredSystems(moduleRow.manifestData as ModuleManifest);
213
+ // API-only module (no host) — nothing to record.
214
+ if (declared.length === 0) continue;
215
+
216
+ const configs = db
217
+ .select()
218
+ .from(moduleConfigs)
219
+ .where(eq(moduleConfigs.moduleId, infra.moduleId))
220
+ .all();
221
+ const cfg = (key: string) => configs.find((c) => c.key === key)?.value;
222
+
223
+ const hostname = cfg('hostname');
224
+ if (!hostname) continue;
225
+
226
+ // IP: resolved target_ip, else ip.primary, else the assigned machine's own
227
+ // IP (machine-pool deploy where neither was written). Mirrors
228
+ // recordDeployedSystemForModule. CIDR is stripped by upsertDeployedSystem.
229
+ let ip = cfg('target_ip') ?? cfg('ip.primary');
230
+ if (!ip && infra.machineId) {
231
+ ip = db.select().from(machines).where(eq(machines.id, infra.machineId)).get()?.ipAddress;
232
+ }
233
+ if (!ip) continue;
234
+
235
+ const decl = declared[0];
236
+ const zone = asZone(decl.resources.zone) ?? asZone(cfg('zone'));
237
+ // Can't address a system without a zone; skip rather than abort the whole
238
+ // backfill (a re-deploy will record it properly).
239
+ if (!zone) continue;
240
+
241
+ const vmidStr = cfg('vmid');
242
+ const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null;
243
+
244
+ upsertDeployedSystem(db, infra.moduleId, {
245
+ name: decl.name,
246
+ hostname,
247
+ ipv4Address: ip,
248
+ zone,
249
+ infraType: infra.infrastructureType,
250
+ machineId: infra.machineId ?? null,
251
+ serviceId: infra.serviceId ?? null,
252
+ vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null,
253
+ });
254
+ backfilled.push(infra.moduleId);
255
+ }
256
+
257
+ return backfilled;
258
+ }
259
+
260
+ /** Default CIDR prefix length when a zone has no `network.<zone>.subnet` set. */
261
+ const DEFAULT_PREFIX = 24;
262
+
263
+ /** Extract the prefix length ("/24" → 24) from a CIDR; default 24. */
264
+ function prefixOf(subnetCidr: string | undefined): number {
265
+ if (!subnetCidr) return DEFAULT_PREFIX;
266
+ const slash = subnetCidr.indexOf('/');
267
+ if (slash === -1) return DEFAULT_PREFIX;
268
+ const n = Number.parseInt(subnetCidr.slice(slash + 1), 10);
269
+ return Number.isNaN(n) ? DEFAULT_PREFIX : n;
270
+ }
271
+
272
+ /**
273
+ * Build the `$infra:<name>.<field>` lookup for a module — one entry per deployed
274
+ * system, keyed by its `name`. `cidr` is derived from `ipv4_address` + the
275
+ * zone's prefix (from `network.<zone>.subnet` in system config). Consumed by the
276
+ * variable resolver at generate time. v2/MODULE_SYSTEMS_ADDRESSING.md.
277
+ */
278
+ export function buildInfraSystemsMap(
279
+ moduleId: string,
280
+ db: DbClient,
281
+ systemConfig: Record<string, string>,
282
+ ): Record<string, InfraSystemFields> {
283
+ const map: Record<string, InfraSystemFields> = {};
284
+ for (const sys of getModuleSystems(moduleId, db)) {
285
+ const prefix = prefixOf(systemConfig[`network.${sys.zone}.subnet`]);
286
+ map[sys.name] = {
287
+ name: sys.name,
288
+ hostname: sys.hostname,
289
+ ipv4_address: sys.ipv4_address,
290
+ zone: sys.zone,
291
+ cidr: `${sys.ipv4_address}/${prefix}`,
292
+ vmid: sys.infrastructure.vmid != null ? String(sys.infrastructure.vmid) : '',
293
+ };
294
+ }
295
+ return map;
296
+ }
297
+
298
+ /** Remove all deployed-system rows for a module (used on uninstall/teardown). */
299
+ export function deleteModuleSystems(db: DbClient, moduleId: string): void {
300
+ db.delete(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).run();
301
+ }
302
+
303
+ /** Remove a single deployed system (used when one host of N is torn down). */
304
+ export function deleteDeployedSystem(db: DbClient, moduleId: string, hostname: string): void {
305
+ db.delete(moduleSystems)
306
+ .where(and(eq(moduleSystems.moduleId, moduleId), eq(moduleSystems.hostname, hostname)))
307
+ .run();
308
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Deploy-time DNS backfill for a freshly-deployed `dns_internal` provider.
3
+ *
4
+ * Event deliveries bind at emit time, so a provider that deploys late never
5
+ * receives the `system.created` events of systems that came up before it. This
6
+ * backfill closes that gap: when a `dns_internal` provider deploys, it registers
7
+ * every currently-deployed system in its zones by invoking its OWN
8
+ * `on_system_event` hook once per host. The ongoing case (a system deploying
9
+ * AFTER the provider) is handled by the provider's `system.created.*`
10
+ * subscription. See v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md.
11
+ *
12
+ * All DNS mechanics (which zones, register vs delete) live in the module's
13
+ * hook; this file only decides WHICH hosts and invokes the hook. That is the
14
+ * D5 division of labour: celilo owns host inventory, the module owns DNS.
15
+ */
16
+
17
+ import type { HookLogger } from '@celilo/capabilities';
18
+ import { and, eq } from 'drizzle-orm';
19
+ import type { DbClient } from '../db/client';
20
+ import { capabilities as capabilitiesTable, modules } from '../db/schema';
21
+ import { runNamedHook } from '../hooks/run-named-hook';
22
+ import type { HookName } from '../hooks/types';
23
+ import { getModuleSystems } from './deployed-systems';
24
+
25
+ /** True when `moduleId` provides the `dns_internal` capability. */
26
+ export function isDnsInternalProvider(moduleId: string, db: DbClient): boolean {
27
+ const row = db
28
+ .select()
29
+ .from(capabilitiesTable)
30
+ .where(
31
+ and(
32
+ eq(capabilitiesTable.capabilityName, 'dns_internal'),
33
+ eq(capabilitiesTable.moduleId, moduleId),
34
+ ),
35
+ )
36
+ .get();
37
+ return Boolean(row);
38
+ }
39
+
40
+ /**
41
+ * Register every currently-deployed system in the just-deployed provider's
42
+ * zones, via its `on_system_event` hook (`op: register`). Includes the provider
43
+ * itself, so the provider's own record exists even in daemonless contexts where
44
+ * no dispatcher delivers its `system.created` event.
45
+ *
46
+ * Attempts every host, then throws an aggregated error if any failed (no
47
+ * surprises; v2/issues/ISS-0004) — one bad host doesn't abort the rest.
48
+ */
49
+ export async function backfillProviderDns(
50
+ moduleId: string,
51
+ db: DbClient,
52
+ logger: HookLogger,
53
+ ): Promise<void> {
54
+ logger.info(`dns_internal provider '${moduleId}' deployed — backfilling DNS for all systems`);
55
+
56
+ const deployed = db.select().from(modules).all();
57
+ const failures: string[] = [];
58
+
59
+ // One register per deployed system across all modules — a module with N
60
+ // hosts backfills N records (v2/MODULE_SYSTEMS_ADDRESSING.md).
61
+ for (const mod of deployed) {
62
+ for (const sys of getModuleSystems(mod.id, db)) {
63
+ const result = await runNamedHook(moduleId, 'on_system_event' as HookName, db, logger, {
64
+ inputs: { hostname: sys.hostname, target_ip: sys.ipv4_address, op: 'register' },
65
+ });
66
+ if (!result.success && !result.notDefined) {
67
+ failures.push(`${sys.hostname} (${mod.id}): ${result.error ?? 'unknown error'}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ if (failures.length > 0) {
73
+ throw new Error(`DNS backfill failed for ${failures.length} host(s): ${failures.join('; ')}`);
74
+ }
75
+ }
@@ -17,6 +17,7 @@ import type { HookResult } from '../hooks/types';
17
17
  import type { ModuleManifest } from '../manifest/schema';
18
18
  import { decryptSecret } from '../secrets/encryption';
19
19
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
+ import { getModuleSystems } from './deployed-systems';
20
21
 
21
22
  export interface HealthCheckItem {
22
23
  name: string;
@@ -95,7 +96,12 @@ export async function runModuleHealthCheck(
95
96
  configMap,
96
97
  secretMap,
97
98
  logger,
98
- { debug: true, capabilities: capabilityFunctions, requiredCapabilities },
99
+ {
100
+ debug: true,
101
+ capabilities: capabilityFunctions,
102
+ requiredCapabilities,
103
+ systems: getModuleSystems(moduleId, db),
104
+ },
99
105
  );
100
106
  } else if (options.onProgress) {
101
107
  // TUI mode: emit a progress message instead of drawing FuelGauge
@@ -114,7 +120,12 @@ export async function runModuleHealthCheck(
114
120
  configMap,
115
121
  secretMap,
116
122
  logger,
117
- { debug: false, capabilities: capabilityFunctions, requiredCapabilities },
123
+ {
124
+ debug: false,
125
+ capabilities: capabilityFunctions,
126
+ requiredCapabilities,
127
+ systems: getModuleSystems(moduleId, db),
128
+ },
118
129
  );
119
130
  } else {
120
131
  const gauge = new FuelGauge('Testing app', {
@@ -132,7 +143,12 @@ export async function runModuleHealthCheck(
132
143
  configMap,
133
144
  secretMap,
134
145
  logger,
135
- { debug: false, capabilities: capabilityFunctions, requiredCapabilities },
146
+ {
147
+ debug: false,
148
+ capabilities: capabilityFunctions,
149
+ requiredCapabilities,
150
+ systems: getModuleSystems(moduleId, db),
151
+ },
136
152
  );
137
153
  gauge.stop(hookResult.success);
138
154
  }