@celilo/cli 0.1.5 → 0.1.7

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 (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -5,20 +5,30 @@
5
5
  */
6
6
 
7
7
  import { join } from 'node:path';
8
+ import { ProgressDisplay } from '@celilo/cli-display';
8
9
  import { and, eq } from 'drizzle-orm';
9
10
  import { generateInventory } from '../ansible/inventory';
10
11
  import { FuelGauge } from '../cli/fuel-gauge';
11
- import { log } from '../cli/prompts';
12
+ import { log, setActiveDisplay } from '../cli/prompts';
12
13
  import type { DbClient } from '../db/client';
13
14
  import { capabilities, machines, modules } from '../db/schema';
14
15
  import { loadCapabilityFunctions } from '../hooks/capability-loader';
15
16
  import { invokeHook } from '../hooks/executor';
16
17
  import { createGaugeLogger } from '../hooks/logger';
18
+ import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
17
19
  import type { ModuleManifest } from '../manifest/schema';
18
20
  import { decryptSecret } from '../secrets/encryption';
19
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
- import { interviewForMissingConfig, interviewForMissingSecrets } from './config-interview';
21
- import { getContainerService, getServiceCredentials } from './container-service';
22
+ import { buildResolutionContext } from '../variables/context';
23
+ import {
24
+ autoDeriveMachineConfig,
25
+ findEnsureOnProvider,
26
+ interviewForEnsureInputs,
27
+ interviewForMissingConfig,
28
+ interviewForMissingSecrets,
29
+ renderEnsureRecipe,
30
+ } from './config-interview';
31
+ import { getContainerService } from './container-service';
22
32
  import { executeAnsible } from './deploy-ansible';
23
33
  import { planDeployment } from './deploy-planner';
24
34
  import { waitForSSH } from './deploy-ssh';
@@ -27,6 +37,7 @@ import { validateAndPrepareDeployment } from './deploy-validation';
27
37
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
28
38
  import { findMachineForModule } from './machine-pool';
29
39
  import { deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
40
+ import { buildTerraformEnvForService } from './terraform-env';
30
41
 
31
42
  export interface DeployResult {
32
43
  success: boolean;
@@ -82,6 +93,140 @@ export interface DeployOptions {
82
93
  debug?: boolean;
83
94
  }
84
95
 
96
+ /**
97
+ * Per-attempt context the caller builds for `invokeHookWithEnsureRetry`.
98
+ * Recreated for each retry so the gauge animation and the logger that
99
+ * pipes into it stay paired — and so the interview prompts get a clean
100
+ * terminal between attempts (no animation collision).
101
+ */
102
+ interface HookAttempt {
103
+ gauge: FuelGauge;
104
+ logger: HookLogger;
105
+ invokeOptions: Parameters<typeof invokeHook>[8];
106
+ }
107
+
108
+ /**
109
+ * Run a hook, and if it fails because a capability call discovered that a
110
+ * provider module is missing config, run the cross-module ensure interview
111
+ * against the provider, optionally redeploy it, and retry the hook.
112
+ *
113
+ * The factory is called once per attempt: the helper owns the gauge
114
+ * lifecycle so it can `stopSilent()` before running the interview (clean
115
+ * prompts) and create a fresh gauge for the retry.
116
+ *
117
+ * Loop guard: each (provider, ensure, value) triple is only allowed to
118
+ * trigger an interview once per call — a second hit means the post-action
119
+ * didn't actually pick up the change, and we abort with a circular-ensure
120
+ * error rather than spinning forever.
121
+ *
122
+ * See `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`.
123
+ */
124
+ async function invokeHookWithEnsureRetry(
125
+ modulePath: string,
126
+ hookName: string,
127
+ contractVersion: string,
128
+ hookDef: HookDefinition,
129
+ inputs: Record<string, unknown>,
130
+ config: Record<string, unknown>,
131
+ hookSecrets: Record<string, string>,
132
+ buildAttempt: () => Promise<HookAttempt>,
133
+ db: DbClient,
134
+ deployOptions: DeployOptions,
135
+ ): Promise<HookResult> {
136
+ const seen = new Set<string>();
137
+ let attempt = await buildAttempt();
138
+
139
+ while (true) {
140
+ const result = await invokeHook(
141
+ modulePath,
142
+ hookName,
143
+ contractVersion,
144
+ hookDef,
145
+ inputs,
146
+ config,
147
+ hookSecrets,
148
+ attempt.logger,
149
+ attempt.invokeOptions,
150
+ );
151
+
152
+ if (result.success) {
153
+ attempt.gauge.stop(true);
154
+ return result;
155
+ }
156
+
157
+ if (!result.missingProviderInput) {
158
+ attempt.gauge.stop(false);
159
+ return result;
160
+ }
161
+
162
+ const m = result.missingProviderInput;
163
+ const key = `${m.providerModuleId}|${m.ensureId}|${m.value}`;
164
+ if (seen.has(key)) {
165
+ attempt.gauge.stop(false);
166
+ return {
167
+ ...result,
168
+ error: `Circular ensure: ${m.providerModuleId} still didn't satisfy "${m.ensureId}" for "${m.value}" after the interview ran. Check that the post action (e.g. redeploy_self) actually applies the change.`,
169
+ };
170
+ }
171
+ seen.add(key);
172
+
173
+ const ensure = findEnsureOnProvider(m.providerModuleId, m.ensureId, db);
174
+ if (!ensure) {
175
+ attempt.gauge.stop(false);
176
+ return {
177
+ ...result,
178
+ error:
179
+ `Module "${m.providerModuleId}" doesn't declare an "ensure" block ` +
180
+ `for "${m.ensureId}". Original hook error: ${result.error}`,
181
+ };
182
+ }
183
+
184
+ if (deployOptions.noInteractive) {
185
+ attempt.gauge.stop(false);
186
+ log.error(renderEnsureRecipe(m.providerModuleId, ensure, m.value));
187
+ return result;
188
+ }
189
+
190
+ // Step the gauge out of the way so prompts render on a clean line.
191
+ attempt.gauge.stopSilent();
192
+
193
+ log.info('');
194
+ log.info(
195
+ `${m.providerModuleId} doesn't yet provide ` +
196
+ `${m.ensureId} for "${m.value}" — running cross-module setup.`,
197
+ );
198
+
199
+ const interviewResult = await interviewForEnsureInputs(m.providerModuleId, ensure, m.value, db);
200
+
201
+ if (!interviewResult.success) {
202
+ return {
203
+ ...result,
204
+ error: `Cross-module interview failed: ${interviewResult.error ?? 'unknown'}`,
205
+ };
206
+ }
207
+
208
+ for (const line of interviewResult.applied) {
209
+ log.info(` ✓ ${line}`);
210
+ }
211
+
212
+ if (ensure.post === 'redeploy_self') {
213
+ log.info('');
214
+ log.info(`Redeploying ${m.providerModuleId} to apply changes...`);
215
+ const redeploy = await deployModule(m.providerModuleId, db, deployOptions);
216
+ if (!redeploy.success) {
217
+ return {
218
+ ...result,
219
+ error: `Failed to redeploy ${m.providerModuleId}: ${redeploy.error ?? 'unknown'}`,
220
+ };
221
+ }
222
+ }
223
+
224
+ // Build a fresh attempt for the retry — new gauge, new logger, new
225
+ // capability bindings (capabilities close over the logger).
226
+ attempt = await buildAttempt();
227
+ }
228
+ }
229
+
85
230
  /**
86
231
  * Orchestrate module deployment workflow
87
232
  * Orchestrator function - coordinates deployment phases
@@ -98,27 +243,37 @@ export async function deployModule(
98
243
  ): Promise<DeployResult> {
99
244
  const phases: DeployResult['phases'] = {};
100
245
 
246
+ const display = new ProgressDisplay({
247
+ mode: options.noInteractive ? 'protocol' : 'auto',
248
+ });
249
+ setActiveDisplay(display);
250
+
101
251
  try {
102
- // Check for e2e test containers — live and e2e environments are mutually exclusive
103
- try {
104
- const { execSync } = await import('node:child_process');
105
- const running = execSync('docker ps --format "{{.Names}}" 2>/dev/null', {
106
- encoding: 'utf-8',
107
- timeout: 5000,
108
- });
109
- const e2eContainers = running.split('\n').filter((n) => n.startsWith('celilo-e2e-'));
110
- if (e2eContainers.length > 0) {
111
- return {
112
- success: false,
113
- error:
114
- 'Cannot deploy: e2e test containers are running.\n' +
115
- 'Live and e2e environments are mutually exclusive.\n' +
116
- 'Stop e2e tests first: cd e2e && ./bin/e2e-down',
117
- phases,
118
- };
252
+ // Check for e2e test containers — live and e2e environments are mutually exclusive.
253
+ // Skip when using a test database (integration tests use os.tmpdir() paths).
254
+ const { tmpdir } = await import('node:os');
255
+ const usingTestDb = process.env.CELILO_DB_PATH?.startsWith(tmpdir());
256
+ if (!usingTestDb) {
257
+ try {
258
+ const { execSync } = await import('node:child_process');
259
+ const running = execSync('docker ps --format "{{.Names}}" 2>/dev/null', {
260
+ encoding: 'utf-8',
261
+ timeout: 5000,
262
+ });
263
+ const e2eContainers = running.split('\n').filter((n) => n.startsWith('celilo-e2e-'));
264
+ if (e2eContainers.length > 0) {
265
+ return {
266
+ success: false,
267
+ error:
268
+ 'Cannot deploy: e2e test containers are running.\n' +
269
+ 'Live and e2e environments are mutually exclusive.\n' +
270
+ 'Stop e2e tests first: cele2e down',
271
+ phases,
272
+ };
273
+ }
274
+ } catch {
275
+ // docker ps failed — Docker may not be installed or running, that's fine
119
276
  }
120
- } catch {
121
- // docker ps failed — Docker may not be installed or running, that's fine
122
277
  }
123
278
 
124
279
  log.info(`Deploying module: ${moduleId}`);
@@ -153,7 +308,7 @@ export async function deployModule(
153
308
  const autoGeneratableSecrets = validation.missingVariables.filter(
154
309
  (v) => v.source === 'secret' && v.generate,
155
310
  );
156
- const remainingMissing = validation.missingVariables.filter(
311
+ let remainingMissing = validation.missingVariables.filter(
157
312
  (v) => !(v.source === 'secret' && v.generate),
158
313
  );
159
314
 
@@ -180,6 +335,31 @@ export async function deployModule(
180
335
  await generateAnsibleSecrets(moduleId, secretsYamlPath, db);
181
336
  }
182
337
 
338
+ // Always auto-derive $machine: variables — even in non-interactive mode.
339
+ // Machine data (interfaces, zone IPs) is catalogued at machine add time and
340
+ // should never require user prompting.
341
+ const machineDerivable = remainingMissing.filter((v) =>
342
+ v.derive_from?.startsWith('$machine:'),
343
+ );
344
+ if (machineDerivable.length > 0) {
345
+ const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
346
+ const moduleZone = manifest.requires?.machine?.zone;
347
+ const matchedMachine = await findMachineForModule(
348
+ moduleId,
349
+ moduleZone,
350
+ isFirewall ? 'router' : undefined,
351
+ );
352
+ if (matchedMachine) {
353
+ const derived = await autoDeriveMachineConfig(
354
+ moduleId,
355
+ machineDerivable,
356
+ db,
357
+ matchedMachine,
358
+ );
359
+ remainingMissing = remainingMissing.filter((v) => !derived.configured.includes(v.name));
360
+ }
361
+ }
362
+
183
363
  if (remainingMissing.length > 0 && options.noInteractive) {
184
364
  // Non-interactive mode: fail with error for remaining missing variables
185
365
  const varList = remainingMissing.map((v) => ` - ${v.name} (${v.source})`).join('\n');
@@ -250,6 +430,26 @@ export async function deployModule(
250
430
  }
251
431
  }
252
432
 
433
+ // If validation returned early (missing variables were present), generation
434
+ // was deferred until after the interview. Run it now that all vars are set.
435
+ if (!validation.autoGenerated) {
436
+ const { generateTemplates } = await import('../templates/generator');
437
+ const regenResult = await generateTemplates({
438
+ moduleId,
439
+ modulePath: module.sourcePath,
440
+ outputPath: generatedPath,
441
+ db,
442
+ skipVariableValidation: false,
443
+ });
444
+ if (!regenResult.success) {
445
+ return {
446
+ success: false,
447
+ error: `Generation failed: ${regenResult.error || 'Unknown error'}`,
448
+ phases,
449
+ };
450
+ }
451
+ }
452
+
253
453
  log.success('Validation passed:');
254
454
  log.message(' Templates generated');
255
455
  if (validation.autoBuilt) {
@@ -391,6 +591,85 @@ export async function deployModule(
391
591
  if (isConfigOnly) {
392
592
  log.success('Config-only module — no infrastructure deployment needed');
393
593
 
594
+ // Run on_install hook for config-only modules (e.g. publishing static files to caddy)
595
+ if (manifest.hooks?.on_install) {
596
+ const onInstallDef = manifest.hooks.on_install;
597
+ log.info('Running on_install hook...');
598
+
599
+ const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
600
+ const installConfigs = db
601
+ .select()
602
+ .from(pcTable)
603
+ .where(eq(pcTable.moduleId, moduleId))
604
+ .all();
605
+ const installConfigMap: Record<string, unknown> = {};
606
+ for (const c of installConfigs) {
607
+ installConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
608
+ }
609
+
610
+ const installContext = await buildResolutionContext(moduleId, db);
611
+ for (const [key, value] of Object.entries(installContext.selfConfig)) {
612
+ if (
613
+ !(key in installConfigMap) ||
614
+ (typeof installConfigMap[key] === 'string' &&
615
+ (installConfigMap[key] as string).startsWith('$'))
616
+ ) {
617
+ installConfigMap[key] = value;
618
+ }
619
+ }
620
+
621
+ const installSecrets = db
622
+ .select()
623
+ .from(secretsTable)
624
+ .where(eq(secretsTable.moduleId, moduleId))
625
+ .all();
626
+ const installMasterKey = await getOrCreateMasterKey();
627
+ const installSecretMap: Record<string, string> = {};
628
+ for (const s of installSecrets) {
629
+ installSecretMap[s.name] = decryptSecret(
630
+ { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
631
+ installMasterKey,
632
+ );
633
+ }
634
+
635
+ const installResult = await invokeHookWithEnsureRetry(
636
+ module.sourcePath,
637
+ 'on_install',
638
+ manifest.celilo_contract,
639
+ onInstallDef,
640
+ {},
641
+ installConfigMap,
642
+ installSecretMap,
643
+ async () => {
644
+ const gauge = new FuelGauge(`${moduleId}: on_install`, {
645
+ skipAnimation: options.noInteractive,
646
+ });
647
+ gauge.start();
648
+ const logger = createGaugeLogger(gauge, moduleId, 'on_install');
649
+ const capFns = await loadCapabilityFunctions(moduleId, db, logger);
650
+ return {
651
+ gauge,
652
+ logger,
653
+ invokeOptions: {
654
+ debug: options.debug,
655
+ capabilities: capFns,
656
+ requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
657
+ },
658
+ };
659
+ },
660
+ db,
661
+ options,
662
+ );
663
+
664
+ if (!installResult.success) {
665
+ log.error(`on_install hook failed: ${installResult.error}`);
666
+ log.error('Module remains in DEPLOYING state. Fix the issue and retry:');
667
+ log.error(` celilo module run-hook ${moduleId} on_install`);
668
+ log.error(` celilo module deploy ${moduleId}`);
669
+ return { success: false, phases, error: installResult.error };
670
+ }
671
+ }
672
+
394
673
  // Transition to INSTALLED
395
674
  db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
396
675
 
@@ -402,9 +681,17 @@ export async function deployModule(
402
681
  debug: options.debug,
403
682
  noInteractive: options.noInteractive,
404
683
  });
405
- if (healthResult.status === 'healthy' || healthResult.status === 'degraded') {
406
- log.success('Health checks passed VERIFIED');
684
+ if (healthResult.status === 'error') {
685
+ return { success: false, phases, error: `Health check failed: ${healthResult.error}` };
686
+ }
687
+ if (healthResult.status === 'unhealthy') {
688
+ const failedChecks = healthResult.checks
689
+ .filter((c) => c.status === 'fail')
690
+ .map((c) => ` ✗ ${c.name}: ${c.message}`)
691
+ .join('\n');
692
+ return { success: false, phases, error: `Health checks failed:\n${failedChecks}` };
407
693
  }
694
+ log.success('Health checks passed → VERIFIED');
408
695
  }
409
696
 
410
697
  return {
@@ -421,8 +708,9 @@ export async function deployModule(
421
708
 
422
709
  let terraformOutputs: Record<string, unknown> | null = null;
423
710
  if (plan.needsTerraform) {
424
- // Convert service credentials to TF_VAR_* environment variables
425
- const terraformEnvVars: Record<string, string> = {};
711
+ // Compose TF_VAR_* env vars from the bound container service's
712
+ // credentials. (Empty for machine-pool deployments.)
713
+ let terraformEnvVars: Record<string, string> = {};
426
714
  if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
427
715
  const service = await getContainerService(plan.infrastructure.serviceId);
428
716
  if (!service) {
@@ -432,17 +720,7 @@ export async function deployModule(
432
720
  phases,
433
721
  };
434
722
  }
435
-
436
- const credentials = await getServiceCredentials(plan.infrastructure.serviceId);
437
-
438
- // Convert credentials to Terraform environment variables based on provider
439
- if (service.providerName === 'digitalocean' && 'api_token' in credentials) {
440
- terraformEnvVars.TF_VAR_digitalocean_token = credentials.api_token;
441
- } else if (service.providerName === 'proxmox' && 'api_url' in credentials) {
442
- terraformEnvVars.TF_VAR_proxmox_api_url = credentials.api_url;
443
- terraformEnvVars.TF_VAR_proxmox_token_id = credentials.api_token_id;
444
- terraformEnvVars.TF_VAR_proxmox_token_secret = credentials.api_token_secret;
445
- }
723
+ terraformEnvVars = await buildTerraformEnvForService(plan.infrastructure.serviceId);
446
724
  }
447
725
 
448
726
  const terraformResult = await executeTerraform(generatedPath, phases, terraformEnvVars, {
@@ -724,11 +1002,6 @@ export async function deployModule(
724
1002
  if (manifest.hooks?.on_install) {
725
1003
  const onInstallDef = manifest.hooks.on_install;
726
1004
  log.info('Running on_install hook...');
727
- const gauge = new FuelGauge(`${moduleId}: on_install`, {
728
- skipAnimation: options.noInteractive,
729
- });
730
- gauge.start();
731
- const hookLogger = createGaugeLogger(gauge, moduleId, 'on_install');
732
1005
 
733
1006
  const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
734
1007
  const installConfigs = db
@@ -741,6 +1014,21 @@ export async function deployModule(
741
1014
  installConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
742
1015
  }
743
1016
 
1017
+ // Resolve capability-derived variables (e.g. source: capability with derive_from).
1018
+ // buildResolutionContext resolves $self: refs in capability data and applies
1019
+ // declarative derivations so that $capability:dns_registrar.primary_domain
1020
+ // yields "iamtheinternet.org" rather than the raw "$self:primary_domain" template.
1021
+ const installContext = await buildResolutionContext(moduleId, db);
1022
+ for (const [key, value] of Object.entries(installContext.selfConfig)) {
1023
+ if (
1024
+ !(key in installConfigMap) ||
1025
+ (typeof installConfigMap[key] === 'string' &&
1026
+ (installConfigMap[key] as string).startsWith('$'))
1027
+ ) {
1028
+ installConfigMap[key] = value;
1029
+ }
1030
+ }
1031
+
744
1032
  // Inject target IP into hook config — works for both machine and container deploys
745
1033
  if (machineId) {
746
1034
  const { getMachine } = await import('./machine-pool');
@@ -765,8 +1053,7 @@ export async function deployModule(
765
1053
  );
766
1054
  }
767
1055
 
768
- const installCapFunctions = await loadCapabilityFunctions(moduleId, db, hookLogger);
769
- const installResult = await invokeHook(
1056
+ const installResult = await invokeHookWithEnsureRetry(
770
1057
  module.sourcePath,
771
1058
  'on_install',
772
1059
  manifest.celilo_contract,
@@ -774,16 +1061,28 @@ export async function deployModule(
774
1061
  {},
775
1062
  installConfigMap,
776
1063
  installSecretMap,
777
- hookLogger,
778
- {
779
- debug: options.debug,
780
- capabilities: installCapFunctions,
781
- requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1064
+ async () => {
1065
+ const gauge = new FuelGauge(`${moduleId}: on_install`, {
1066
+ skipAnimation: options.noInteractive,
1067
+ });
1068
+ gauge.start();
1069
+ const logger = createGaugeLogger(gauge, moduleId, 'on_install');
1070
+ const capFns = await loadCapabilityFunctions(moduleId, db, logger);
1071
+ return {
1072
+ gauge,
1073
+ logger,
1074
+ invokeOptions: {
1075
+ debug: options.debug,
1076
+ capabilities: capFns,
1077
+ requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1078
+ },
1079
+ };
782
1080
  },
1081
+ db,
1082
+ options,
783
1083
  );
784
1084
 
785
1085
  if (!installResult.success) {
786
- gauge.stop(false);
787
1086
  log.error(`on_install hook failed: ${installResult.error}`);
788
1087
  log.error('Module remains in DEPLOYED state. Fix the issue and retry:');
789
1088
  log.error(` celilo module run-hook ${moduleId} on_install`);
@@ -794,7 +1093,6 @@ export async function deployModule(
794
1093
  error: installResult.error,
795
1094
  };
796
1095
  }
797
- gauge.stop(true);
798
1096
  }
799
1097
 
800
1098
  // Transition to INSTALLED
@@ -809,19 +1107,28 @@ export async function deployModule(
809
1107
  noInteractive: options.noInteractive,
810
1108
  });
811
1109
 
1110
+ if (healthResult.status === 'error') {
1111
+ return {
1112
+ success: false,
1113
+ phases,
1114
+ error: `Health check failed: ${healthResult.error}`,
1115
+ };
1116
+ }
812
1117
  if (healthResult.status === 'unhealthy') {
813
1118
  const failedChecks = healthResult.checks
814
1119
  .filter((c) => c.status === 'fail')
815
1120
  .map((c) => ` ✗ ${c.name}: ${c.message}`)
816
1121
  .join('\n');
817
- log.warn(`Health checks failed:\n${failedChecks}`);
818
- log.warn(`Module is INSTALLED but not VERIFIED. Run: celilo module health ${moduleId}`);
819
- } else {
820
- const summary = healthResult.checks
821
- .map((c) => ` ${c.status === 'pass' ? '✓' : '⚠'} ${c.name}`)
822
- .join('\n');
823
- log.success(`Health checks passed → VERIFIED\n${summary}`);
1122
+ return {
1123
+ success: false,
1124
+ phases,
1125
+ error: `Health checks failed:\n${failedChecks}`,
1126
+ };
824
1127
  }
1128
+ const summary = healthResult.checks
1129
+ .map((c) => ` ${c.status === 'pass' ? '✓' : '⚠'} ${c.name}`)
1130
+ .join('\n');
1131
+ log.success(`Health checks passed → VERIFIED\n${summary}`);
825
1132
  }
826
1133
 
827
1134
  // Auto-register module hostname in internal DNS (if available)
@@ -841,6 +1148,7 @@ export async function deployModule(
841
1148
  );
842
1149
  }
843
1150
 
1151
+ display.instantEvent(`\x1b[32m✓\x1b[0m Module '${moduleId}' deployed successfully`);
844
1152
  return {
845
1153
  success: true,
846
1154
  phases,
@@ -858,5 +1166,8 @@ export async function deployModule(
858
1166
  error: error instanceof Error ? error.message : 'Unknown error',
859
1167
  phases,
860
1168
  };
1169
+ } finally {
1170
+ display.flush();
1171
+ setActiveDisplay(null);
861
1172
  }
862
1173
  }
@@ -101,7 +101,7 @@ describe('ssh-key-manager', () => {
101
101
 
102
102
  const keyPath = await writeTemporarySshKey(machine.id);
103
103
 
104
- expect(keyPath).toContain('tmp/ansible-keys');
104
+ expect(keyPath).toContain('celilo-ansible-keys');
105
105
  expect(keyPath).toContain(`machine-${machine.id}.key`);
106
106
  });
107
107
  });
@@ -5,15 +5,16 @@
5
5
  */
6
6
 
7
7
  import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
8
9
  import { join } from 'node:path';
9
- import { getDataDir } from '../config/paths';
10
10
  import { getMachineSshKey } from './machine-pool';
11
11
 
12
12
  /**
13
13
  * Get the temporary SSH keys directory
14
+ * Uses OS temp directory to avoid EROFS issues with read-only data mounts
14
15
  */
15
16
  function getTempKeysDir(): string {
16
- return join(getDataDir(), 'tmp', 'ansible-keys');
17
+ return join(tmpdir(), 'celilo-ansible-keys');
17
18
  }
18
19
 
19
20
  /**
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Compose `TF_VAR_*` environment variables from a module's bound
3
+ * container-service credentials. Shared between `module-deploy`
4
+ * (which executes terraform during deployment) and `system audit`'s
5
+ * terraform-plan check (which needs the same credentials to run a
6
+ * non-mutating `terraform plan`).
7
+ *
8
+ * Modules bound to a machine instead of a container service get an
9
+ * empty record — there are no provider credentials to inject.
10
+ */
11
+
12
+ import { eq } from 'drizzle-orm';
13
+ import type { DbClient } from '../db/client';
14
+ import { moduleInfrastructure } from '../db/schema';
15
+ import { getContainerService, getServiceCredentials } from './container-service';
16
+
17
+ /**
18
+ * Build the `TF_VAR_*` env-var record for a single module. Returns
19
+ * an empty record when the module isn't bound to a container service
20
+ * (e.g. machine-pool deployments) or when credentials aren't
21
+ * available. Never throws — failure to look up credentials is
22
+ * surfaced as a missing var, which terraform will then complain
23
+ * about in a way the caller already handles.
24
+ */
25
+ export async function buildTerraformEnvForModule(
26
+ moduleId: string,
27
+ db: DbClient,
28
+ ): Promise<Record<string, string>> {
29
+ const infra = db
30
+ .select()
31
+ .from(moduleInfrastructure)
32
+ .where(eq(moduleInfrastructure.moduleId, moduleId))
33
+ .get();
34
+ if (!infra || infra.infrastructureType !== 'container_service' || !infra.serviceId) {
35
+ return {};
36
+ }
37
+ return await buildTerraformEnvForService(infra.serviceId);
38
+ }
39
+
40
+ /**
41
+ * Build the `TF_VAR_*` env-var record for a container service
42
+ * directly. Caller is responsible for already knowing the service
43
+ * ID (e.g. via `planDeployment`).
44
+ */
45
+ export async function buildTerraformEnvForService(
46
+ serviceId: string,
47
+ ): Promise<Record<string, string>> {
48
+ const service = await getContainerService(serviceId);
49
+ if (!service) return {};
50
+
51
+ const credentials = await getServiceCredentials(serviceId);
52
+
53
+ const env: Record<string, string> = {};
54
+ if (service.providerName === 'digitalocean' && 'api_token' in credentials) {
55
+ env.TF_VAR_digitalocean_token = credentials.api_token as string;
56
+ } else if (service.providerName === 'proxmox' && 'api_url' in credentials) {
57
+ env.TF_VAR_proxmox_api_url = credentials.api_url as string;
58
+ env.TF_VAR_proxmox_token_id = credentials.api_token_id as string;
59
+ env.TF_VAR_proxmox_token_secret = credentials.api_token_secret as string;
60
+ }
61
+ return env;
62
+ }