@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
@@ -70,6 +70,75 @@ export async function parseConfigValue(
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * Upsert a `module_configs` row. THE ONLY supported write path.
75
+ *
76
+ * Direct `db.insert(moduleConfigs).values({...})` calls are an
77
+ * anti-pattern — they bypass valueJson population and leave reads
78
+ * unable to recover the original type. Defect 1 was exactly this:
79
+ * primitive writes that set `value` but not `valueJson`, so reads
80
+ * had to guess (and got it wrong for stringly-looking-numeric
81
+ * values, etc.). Every write site routes through here now.
82
+ *
83
+ * Callers pass the canonical JS value (`number`, `boolean`,
84
+ * `string`, array, object); we JSON-stringify into `valueJson`
85
+ * (the typed canonical) and produce a human-readable form for
86
+ * `value` (used only for CLI display).
87
+ */
88
+ export function upsertModuleConfig(
89
+ db: DbClient,
90
+ moduleId: string,
91
+ key: string,
92
+ value: string | number | boolean | unknown[] | Record<string, unknown> | null,
93
+ ): void {
94
+ if (value === null || value === undefined) {
95
+ throw new Error(`upsertModuleConfig(${moduleId}, ${key}): value must not be null/undefined`);
96
+ }
97
+ const valueJson = JSON.stringify(value);
98
+ const displayValue = isComplexValue(value) ? valueJson : String(value);
99
+
100
+ db.insert(moduleConfigs)
101
+ .values({ moduleId, key, value: displayValue, valueJson })
102
+ .onConflictDoUpdate({
103
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
104
+ set: { value: displayValue, valueJson, updatedAt: new Date() },
105
+ })
106
+ .run();
107
+ }
108
+
109
+ /**
110
+ * Parse a stored module_configs row into its canonical typed value.
111
+ * Throws if valueJson is null — that's a row written before the
112
+ * always-populate-valueJson invariant, and we no longer support it.
113
+ * See schema.ts module_configs doc comment for context.
114
+ *
115
+ * Exported so other read sites (hook config loader, variables
116
+ * context, ansible inventory builder, etc.) can share one parsing
117
+ * path — a single source of truth for "DB row → typed value".
118
+ */
119
+ export function parseStoredConfigValue(
120
+ row: typeof moduleConfigs.$inferSelect,
121
+ ): string | number | boolean | unknown[] | Record<string, unknown> {
122
+ if (row.valueJson === null || row.valueJson === undefined) {
123
+ throw new Error(
124
+ `module_configs row ${row.moduleId}.${row.key} has null valueJson — ` +
125
+ `pre-Defect-1 row, no longer supported. Re-run \`celilo module config set ${row.moduleId} ${row.key} <value>\` to populate.`,
126
+ );
127
+ }
128
+ try {
129
+ return JSON.parse(row.valueJson) as
130
+ | string
131
+ | number
132
+ | boolean
133
+ | unknown[]
134
+ | Record<string, unknown>;
135
+ } catch (error) {
136
+ throw new Error(
137
+ `Failed to parse valueJson for ${row.moduleId}.${row.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
138
+ );
139
+ }
140
+ }
141
+
73
142
  /**
74
143
  * Get module configuration value
75
144
  * Returns parsed value (primitive or complex type)
@@ -89,41 +158,11 @@ export function getModuleConfigValue(
89
158
  return null;
90
159
  }
91
160
 
92
- // Check if complex type (stored in valueJson)
93
- if (config.valueJson) {
94
- try {
95
- const parsed = JSON.parse(config.valueJson);
96
- return {
97
- key: config.key,
98
- value: parsed,
99
- isPrimitive: false,
100
- };
101
- } catch (error) {
102
- throw new Error(
103
- `Failed to parse config value for ${moduleId}.${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
104
- );
105
- }
106
- }
107
-
108
- // Primitive type (stored in value)
109
- // Parse the string value to correct type
110
- let parsedValue: string | number | boolean = config.value;
111
-
112
- // Boolean
113
- if (config.value === 'true') parsedValue = true;
114
- else if (config.value === 'false') parsedValue = false;
115
- // Number
116
- else {
117
- const num = Number(config.value);
118
- if (!Number.isNaN(num)) {
119
- parsedValue = num;
120
- }
121
- }
122
-
161
+ const parsed = parseStoredConfigValue(config);
123
162
  return {
124
163
  key: config.key,
125
- value: parsedValue,
126
- isPrimitive: true,
164
+ value: parsed,
165
+ isPrimitive: !isComplexValue(parsed),
127
166
  };
128
167
  }
129
168
 
@@ -134,40 +173,11 @@ export function getAllModuleConfigValues(moduleId: string, db: DbClient = getDb(
134
173
  const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
135
174
 
136
175
  return configs.map((config: typeof moduleConfigs.$inferSelect) => {
137
- // Check if complex type
138
- if (config.valueJson) {
139
- try {
140
- const parsed = JSON.parse(config.valueJson);
141
- return {
142
- key: config.key,
143
- value: parsed,
144
- isPrimitive: false,
145
- };
146
- } catch (error) {
147
- throw new Error(
148
- `Failed to parse config value for ${moduleId}.${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
149
- );
150
- }
151
- }
152
-
153
- // Primitive type
154
- let parsedValue: string | number | boolean = config.value;
155
-
156
- // Boolean
157
- if (config.value === 'true') parsedValue = true;
158
- else if (config.value === 'false') parsedValue = false;
159
- // Number
160
- else {
161
- const num = Number(config.value);
162
- if (!Number.isNaN(num)) {
163
- parsedValue = num;
164
- }
165
- }
166
-
176
+ const parsed = parseStoredConfigValue(config);
167
177
  return {
168
178
  key: config.key,
169
- value: parsedValue,
170
- isPrimitive: true,
179
+ value: parsed,
180
+ isPrimitive: !isComplexValue(parsed),
171
181
  };
172
182
  });
173
183
  }
@@ -228,61 +238,9 @@ export async function setModuleConfigValue(
228
238
  throw new Error(formatValidationErrors(validation.errors || []));
229
239
  }
230
240
 
231
- // Determine if complex type
232
- const isComplex = isComplexValue(parsedValue);
233
-
234
- // Check if config already exists
235
- const existingConfig = db
236
- .select()
237
- .from(moduleConfigs)
238
- .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
239
- .get();
240
-
241
- if (existingConfig) {
242
- // Update existing config
243
- if (isComplex) {
244
- // Store in valueJson column
245
- db.update(moduleConfigs)
246
- .set({
247
- value: '', // Empty string for complex types
248
- valueJson: JSON.stringify(parsedValue),
249
- updatedAt: new Date(),
250
- })
251
- .where(eq(moduleConfigs.id, existingConfig.id))
252
- .run();
253
- } else {
254
- // Store in value column
255
- db.update(moduleConfigs)
256
- .set({
257
- value: String(parsedValue),
258
- valueJson: null,
259
- updatedAt: new Date(),
260
- })
261
- .where(eq(moduleConfigs.id, existingConfig.id))
262
- .run();
263
- }
264
- } else {
265
- // Insert new config
266
- if (isComplex) {
267
- db.insert(moduleConfigs)
268
- .values({
269
- moduleId,
270
- key,
271
- value: '', // Empty string for complex types
272
- valueJson: JSON.stringify(parsedValue),
273
- })
274
- .run();
275
- } else {
276
- db.insert(moduleConfigs)
277
- .values({
278
- moduleId,
279
- key,
280
- value: String(parsedValue),
281
- valueJson: null,
282
- })
283
- .run();
284
- }
285
- }
241
+ // Delegate to the shared upsert helper — single storage path,
242
+ // single place where the valueJson invariant lives.
243
+ upsertModuleConfig(db, moduleId, key, parsedValue);
286
244
  }
287
245
 
288
246
  /**
@@ -20,6 +20,7 @@ import type { ModuleManifest } from '../manifest/schema';
20
20
  import { decryptSecret } from '../secrets/encryption';
21
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
22
22
  import { buildResolutionContext } from '../variables/context';
23
+ import { maybeRunAspectForTrigger } from './aspect-runner';
23
24
  import {
24
25
  autoDeriveMachineConfig,
25
26
  findEnsureOnProvider,
@@ -33,6 +34,7 @@ import { planDeployment } from './deploy-planner';
33
34
  import { waitForSSH } from './deploy-ssh';
34
35
  import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
35
36
  import { validateAndPrepareDeployment } from './deploy-validation';
37
+ import { getModuleSystems } from './deployed-systems';
36
38
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
37
39
  import { findMachineForModule } from './machine-pool';
38
40
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
@@ -263,7 +265,9 @@ export async function deployModule(
263
265
  const startedAt = Date.now();
264
266
  const { emitDeployCompleted, emitDeployFailed, emitDeployStarted, emitHealthCheckFailed } =
265
267
  await import('./celilo-events');
268
+ const { startOperation, completeOperation, failOperation } = await import('./module-operations');
266
269
 
270
+ const opId = startOperation(moduleId, 'deploy');
267
271
  emitDeployStarted({ module: moduleId, startedAt });
268
272
 
269
273
  let result: DeployResult;
@@ -271,19 +275,22 @@ export async function deployModule(
271
275
  result = await deployModuleImpl(moduleId, db, options);
272
276
  } catch (err) {
273
277
  const error = err instanceof Error ? err.message : String(err);
278
+ failOperation(opId, err);
274
279
  emitDeployFailed({
275
280
  module: moduleId,
276
281
  startedAt,
277
- durationMs: Date.now() - startedAt,
282
+ durationMs: Math.max(0, Date.now() - startedAt),
278
283
  error,
279
284
  });
280
285
  throw err;
281
286
  }
282
287
 
283
- const durationMs = Date.now() - startedAt;
288
+ const durationMs = Math.max(0, Date.now() - startedAt);
284
289
  if (result.success) {
290
+ completeOperation(opId);
285
291
  emitDeployCompleted({ module: moduleId, startedAt, durationMs });
286
292
  } else {
293
+ failOperation(opId, result.error ?? 'unknown error');
287
294
  emitDeployFailed({
288
295
  module: moduleId,
289
296
  startedAt,
@@ -607,6 +614,7 @@ async function deployModuleImpl(
607
614
  debug: options.debug,
608
615
  capabilities: capabilityFunctions,
609
616
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
617
+ systems: getModuleSystems(moduleId, db),
610
618
  },
611
619
  );
612
620
 
@@ -745,6 +753,7 @@ async function deployModuleImpl(
745
753
  debug: options.debug,
746
754
  capabilities: capFns,
747
755
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
756
+ systems: getModuleSystems(moduleId, db),
748
757
  },
749
758
  };
750
759
  },
@@ -783,6 +792,25 @@ async function deployModuleImpl(
783
792
  }
784
793
  }
785
794
 
795
+ // Fan out the module's base-module aspect (if any) per the
796
+ // on_install trigger. Failures here don't fail the primary
797
+ // deploy — D4 in v2/CELILO_BASE.md: aspects are idempotent
798
+ // and forward-progress; a partial fleet update is expected to
799
+ // converge on the next fan-out. We log the result instead.
800
+ const aspectOutcome = await maybeRunAspectForTrigger({
801
+ moduleId,
802
+ manifest,
803
+ trigger: 'on_install',
804
+ db,
805
+ });
806
+ if (aspectOutcome.ran && !aspectOutcome.success) {
807
+ log.warn(
808
+ `Base-module aspect fan-out for '${moduleId}' failed: ${aspectOutcome.runResult?.error ?? 'unknown'}. Primary deploy succeeded; re-run \`celilo fleet redeploy ${moduleId}\` after fixing.`,
809
+ );
810
+ } else if (aspectOutcome.ran) {
811
+ log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
812
+ }
813
+
786
814
  // Mirror the infrastructure-path success message at the end of
787
815
  // a successful deploy. Without this, config-only deploys end
788
816
  // abruptly with whatever the last hook line was — operator sees
@@ -875,6 +903,25 @@ async function deployModuleImpl(
875
903
  db,
876
904
  );
877
905
 
906
+ // Record the module's deployed system(s) now that the IP is known for every
907
+ // provider type (machine / proxmox IPAM / DO outputs). This populates
908
+ // ctx.systems for on_install and is the source of truth for system.created
909
+ // and DNS (v2/MODULE_SYSTEMS_ADDRESSING.md). API-only modules record none.
910
+ {
911
+ const { recordDeployedSystemForModule } = await import('./deployed-systems');
912
+ const recorded = await recordDeployedSystemForModule(
913
+ moduleId,
914
+ manifest,
915
+ plan.infrastructure,
916
+ db,
917
+ );
918
+ if (recorded.length > 0) {
919
+ log.success(
920
+ `Recorded ${recorded.length} deployed system(s): ${recorded.map((s) => `${s.hostname} (${s.ipv4_address})`).join(', ')}`,
921
+ );
922
+ }
923
+ }
924
+
878
925
  if (Object.keys(resolution.resolved).length > 0) {
879
926
  // Build complete message with all variables
880
927
  const lines = ['Infrastructure variables resolved:'];
@@ -999,6 +1046,7 @@ async function deployModuleImpl(
999
1046
  debug: options.debug,
1000
1047
  capabilities: capFunctions,
1001
1048
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1049
+ systems: getModuleSystems(capRecord.moduleId, db),
1002
1050
  },
1003
1051
  );
1004
1052
 
@@ -1137,31 +1185,15 @@ async function deployModuleImpl(
1137
1185
  }
1138
1186
  }
1139
1187
 
1140
- // Persist target_ip to moduleConfigs (not just inject into the
1141
- // in-memory hook context). Later hooks like on_backup build
1142
- // their context from the DB only, so without this they'd see
1143
- // no target_ip and fail with "No container IP configured".
1144
- // Mirrors proxmox-state-recovery.ts for the machine-deployed
1145
- // case.
1188
+ // Inject the machine's IP as ip.primary for hooks that still read it
1189
+ // (firewall NAT target, etc.). The host's address for hooks now comes
1190
+ // from ctx.systems (v2/MODULE_SYSTEMS_ADDRESSING.md), recorded into
1191
+ // module_systems during generate no target_ip is written here.
1146
1192
  if (machineId) {
1147
1193
  const { getMachine } = await import('./machine-pool');
1148
1194
  const deployMachine = await getMachine(machineId);
1149
1195
  if (deployMachine) {
1150
1196
  installConfigMap['ip.primary'] = deployMachine.ipAddress;
1151
- installConfigMap.target_ip = deployMachine.ipAddress;
1152
-
1153
- await db
1154
- .insert(pcTable)
1155
- .values({
1156
- moduleId,
1157
- key: 'target_ip',
1158
- value: deployMachine.ipAddress,
1159
- })
1160
- .onConflictDoUpdate({
1161
- target: [pcTable.moduleId, pcTable.key],
1162
- set: { value: deployMachine.ipAddress },
1163
- })
1164
- .run();
1165
1197
  }
1166
1198
  }
1167
1199
 
@@ -1201,6 +1233,7 @@ async function deployModuleImpl(
1201
1233
  debug: options.debug,
1202
1234
  capabilities: capFns,
1203
1235
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1236
+ systems: getModuleSystems(moduleId, db),
1204
1237
  },
1205
1238
  };
1206
1239
  },
@@ -1253,31 +1286,71 @@ async function deployModuleImpl(
1253
1286
  }
1254
1287
 
1255
1288
  // Auto-register module hostname in internal DNS (if available).
1256
- // Wrapped in a FuelGauge with a gauge logger so the capability calls
1257
- // inside (`dns_internal.registerRecord`, etc.) nest as sub-events
1258
- // under a single step rather than leaking to scrollback as
1259
- // unindented top-level lines via cli/prompts.log.instantEvent.
1260
- let dnsRegistered = true;
1261
- const dnsGauge = new FuelGauge(`Registering ${moduleId} in DNS`, {
1262
- skipAnimation: !process.stdout.isTTY,
1263
- });
1264
- dnsGauge.start();
1289
+ // When a dns_internal provider deploys, backfill DNS for every
1290
+ // already-deployed system by invoking its own on_system_event hook per
1291
+ // host the event path below only covers systems that deploy AFTER the
1292
+ // provider (deliveries bind at emit time). Non-providers skip this
1293
+ // entirely; their registration rides the system.created event below.
1294
+ // v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md.
1295
+ const { isDnsInternalProvider, backfillProviderDns } = await import(
1296
+ './dns-provider-backfill'
1297
+ );
1298
+ if (isDnsInternalProvider(moduleId, db)) {
1299
+ // FuelGauge so the per-host hook invocations nest as sub-events under
1300
+ // one step rather than leaking to scrollback.
1301
+ const dnsGauge = new FuelGauge(`Backfilling internal DNS via ${moduleId}`, {
1302
+ skipAnimation: !process.stdout.isTTY,
1303
+ });
1304
+ dnsGauge.start();
1305
+ try {
1306
+ const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'dns_backfill');
1307
+ await backfillProviderDns(moduleId, db, dnsLogger);
1308
+ dnsGauge.stop(true);
1309
+ } catch (error) {
1310
+ dnsGauge.stop(false);
1311
+ const msg = error instanceof Error ? error.message : String(error);
1312
+ log.warn(
1313
+ `DNS backfill failed for '${moduleId}': ${msg}. Some internal DNS records may need manual setup.`,
1314
+ );
1315
+ }
1316
+ }
1317
+
1318
+ // Announce each deployed system on the bus (D5/D6) — one
1319
+ // system.created.<module> per host, so a dns_internal provider's
1320
+ // subscription registers every one (v2/MODULE_SYSTEMS_ADDRESSING.md,
1321
+ // v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md, v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md).
1265
1322
  try {
1266
- const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'auto_register_dns');
1267
- const { autoRegisterDns } = await import('./dns-auto-register');
1268
- await autoRegisterDns(moduleId, db, dnsLogger);
1269
- dnsGauge.stop(true);
1323
+ const { emitSystemCreated } = await import('./celilo-events');
1324
+ for (const sys of getModuleSystems(moduleId, db)) {
1325
+ emitSystemCreated({
1326
+ module: moduleId,
1327
+ hostname: sys.hostname,
1328
+ targetIp: sys.ipv4_address,
1329
+ });
1330
+ }
1270
1331
  } catch (error) {
1271
- dnsGauge.stop(false);
1332
+ // Best-effort: a bus hiccup must not fail an otherwise-good deploy.
1272
1333
  const msg = error instanceof Error ? error.message : String(error);
1273
- log.warn(`DNS auto-registration failed: ${msg}`);
1274
- dnsRegistered = false;
1334
+ log.warn(`Failed to emit system.created for ${moduleId}: ${msg}`);
1275
1335
  }
1276
1336
 
1277
- if (!dnsRegistered) {
1337
+ // Fan out the module's base-module aspect (if any) per the
1338
+ // on_install trigger. Failures here don't fail the primary
1339
+ // deploy — D4 in v2/CELILO_BASE.md: aspects are idempotent
1340
+ // and forward-progress; a partial fleet update is expected to
1341
+ // converge on the next fan-out. We log the result instead.
1342
+ const aspectOutcome = await maybeRunAspectForTrigger({
1343
+ moduleId,
1344
+ manifest,
1345
+ trigger: 'on_install',
1346
+ db,
1347
+ });
1348
+ if (aspectOutcome.ran && !aspectOutcome.success) {
1278
1349
  log.warn(
1279
- `Module '${moduleId}' deployed but DNS registration failed. Internal DNS records may need manual setup.`,
1350
+ `Base-module aspect fan-out for '${moduleId}' failed: ${aspectOutcome.runResult?.error ?? 'unknown'}. Primary deploy succeeded; re-run \`celilo fleet redeploy ${moduleId}\` after fixing.`,
1280
1351
  );
1352
+ } else if (aspectOutcome.ran) {
1353
+ log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
1281
1354
  }
1282
1355
 
1283
1356
  log.success(`Module '${moduleId}' deployed successfully`);
@@ -0,0 +1,154 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../db/client';
7
+ import { runMigrations } from '../db/migrate';
8
+ import {
9
+ InFlightError,
10
+ checkInFlight,
11
+ completeOperation,
12
+ failOperation,
13
+ isPidAlive,
14
+ refuseIfInFlight,
15
+ startOperation,
16
+ } from './module-operations';
17
+
18
+ describe('module-operations', () => {
19
+ let dir: string;
20
+
21
+ beforeEach(async () => {
22
+ dir = mkdtempSync(join(tmpdir(), 'celilo-ops-test-'));
23
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
24
+ await runMigrations(process.env.CELILO_DB_PATH);
25
+ });
26
+
27
+ afterEach(() => {
28
+ closeDb();
29
+ process.env.CELILO_DB_PATH = undefined;
30
+ try {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ } catch {
33
+ /* ignore */
34
+ }
35
+ });
36
+
37
+ describe('startOperation / completeOperation / failOperation', () => {
38
+ it('records an in-progress row on start, transitions to completed on complete', () => {
39
+ const id = startOperation('homebridge', 'deploy');
40
+ const inFlight = checkInFlight();
41
+ expect(inFlight).toHaveLength(1);
42
+ expect(inFlight[0].operation.id).toBe(id);
43
+ expect(inFlight[0].operation.status).toBe('in_progress');
44
+ expect(inFlight[0].operation.operation).toBe('deploy');
45
+ expect(inFlight[0].operation.moduleId).toBe('homebridge');
46
+
47
+ completeOperation(id);
48
+ expect(checkInFlight()).toHaveLength(0);
49
+ });
50
+
51
+ it('transitions to failed with error message on failOperation', () => {
52
+ const id = startOperation('caddy', 'backup');
53
+ failOperation(id, new Error('disk full'));
54
+ expect(checkInFlight()).toHaveLength(0);
55
+
56
+ // Re-querying directly to verify the failed row is recorded
57
+ const id2 = startOperation('caddy', 'backup');
58
+ const inFlight = checkInFlight();
59
+ expect(inFlight).toHaveLength(1);
60
+ expect(inFlight[0].operation.id).toBe(id2);
61
+ });
62
+
63
+ it('failOperation accepts non-Error values', () => {
64
+ const id = startOperation('caddy', 'restore');
65
+ failOperation(id, 'string error reason');
66
+ expect(checkInFlight()).toHaveLength(0);
67
+ });
68
+ });
69
+
70
+ describe('checkInFlight', () => {
71
+ it('returns empty when no operations are in flight', () => {
72
+ expect(checkInFlight()).toHaveLength(0);
73
+ });
74
+
75
+ it('describes conflicts with module + operation + pid', () => {
76
+ const id = startOperation('caddy', 'deploy');
77
+ const conflicts = checkInFlight();
78
+ expect(conflicts).toHaveLength(1);
79
+ expect(conflicts[0].describe).toBe(`deploy of caddy (pid ${process.pid})`);
80
+ completeOperation(id);
81
+ });
82
+
83
+ it('excludes the operation matching excludeOperationId', () => {
84
+ const own = startOperation('caddy', 'backup');
85
+ const other = startOperation('homebridge', 'deploy');
86
+ const conflicts = checkInFlight(own);
87
+ expect(conflicts).toHaveLength(1);
88
+ expect(conflicts[0].operation.id).toBe(other);
89
+ completeOperation(own);
90
+ completeOperation(other);
91
+ });
92
+
93
+ it('ignores rows whose pid is no longer alive', () => {
94
+ // Spawn a short-lived process, capture its pid, wait for it to exit.
95
+ const child = spawnSync('node', ['-e', 'process.exit(0)']);
96
+ const deadPid = child.pid;
97
+ expect(deadPid).toBeGreaterThan(0);
98
+ expect(isPidAlive(deadPid)).toBe(false);
99
+
100
+ // Insert a fake row with the dead pid via raw SQL (bypasses pid=process.pid in startOperation).
101
+ const { getDb } = require('../db/client');
102
+ const { moduleOperations } = require('../db/schema');
103
+ const db = getDb();
104
+ db.insert(moduleOperations)
105
+ .values({
106
+ id: 'fake-dead-row',
107
+ moduleId: 'orphan',
108
+ operation: 'deploy',
109
+ status: 'in_progress',
110
+ pid: deadPid,
111
+ })
112
+ .run();
113
+
114
+ const conflicts = checkInFlight();
115
+ expect(conflicts).toHaveLength(0); // dead pid filtered out
116
+ });
117
+ });
118
+
119
+ describe('refuseIfInFlight', () => {
120
+ it('throws InFlightError when conflicts exist', () => {
121
+ const id = startOperation('caddy', 'deploy');
122
+ expect(() => refuseIfInFlight()).toThrow(InFlightError);
123
+ try {
124
+ refuseIfInFlight();
125
+ } catch (err) {
126
+ expect(err).toBeInstanceOf(InFlightError);
127
+ expect((err as InFlightError).conflicts).toHaveLength(1);
128
+ expect((err as Error).message).toContain('deploy of caddy');
129
+ }
130
+ completeOperation(id);
131
+ });
132
+
133
+ it('is a no-op when no conflicts exist', () => {
134
+ expect(() => refuseIfInFlight()).not.toThrow();
135
+ });
136
+
137
+ it('respects excludeOperationId', () => {
138
+ const own = startOperation('caddy', 'backup');
139
+ expect(() => refuseIfInFlight(own)).not.toThrow();
140
+ completeOperation(own);
141
+ });
142
+ });
143
+
144
+ describe('isPidAlive', () => {
145
+ it('returns true for the current process', () => {
146
+ expect(isPidAlive(process.pid)).toBe(true);
147
+ });
148
+
149
+ it('returns false for a dead pid', () => {
150
+ const child = spawnSync('node', ['-e', 'process.exit(0)']);
151
+ expect(isPidAlive(child.pid as number)).toBe(false);
152
+ });
153
+ });
154
+ });