@celilo/cli 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-show.ts +1 -1
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +3 -3
- package/src/templates/generator.ts +43 -2
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- 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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 === '
|
|
406
|
-
|
|
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
|
-
//
|
|
425
|
-
|
|
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,13 +1014,28 @@ export async function deployModule(
|
|
|
741
1014
|
installConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
742
1015
|
}
|
|
743
1016
|
|
|
744
|
-
//
|
|
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
|
+
|
|
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');
|
|
747
1035
|
const deployMachine = await getMachine(machineId);
|
|
748
1036
|
if (deployMachine) {
|
|
749
1037
|
installConfigMap['ip.primary'] = deployMachine.ipAddress;
|
|
750
|
-
installConfigMap.
|
|
1038
|
+
installConfigMap.target_ip = deployMachine.ipAddress;
|
|
751
1039
|
}
|
|
752
1040
|
}
|
|
753
1041
|
|
|
@@ -765,8 +1053,7 @@ export async function deployModule(
|
|
|
765
1053
|
);
|
|
766
1054
|
}
|
|
767
1055
|
|
|
768
|
-
const
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
}
|
|
@@ -31,7 +31,7 @@ interface TerraformState {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Ensure module_configs has vmid and
|
|
34
|
+
* Ensure module_configs has vmid and target_ip from Terraform state
|
|
35
35
|
* This recovers from state drift scenarios where container was deleted/recreated
|
|
36
36
|
*
|
|
37
37
|
* @param moduleId - Module identifier
|
|
@@ -43,7 +43,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
43
43
|
terraformDir: string,
|
|
44
44
|
db: DbClient,
|
|
45
45
|
): Promise<void> {
|
|
46
|
-
// Check if vmid and
|
|
46
|
+
// Check if vmid and target_ip already exist
|
|
47
47
|
const existingVmid = await db
|
|
48
48
|
.select()
|
|
49
49
|
.from(moduleConfigs)
|
|
@@ -53,7 +53,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
53
53
|
const existingContainerIp = await db
|
|
54
54
|
.select()
|
|
55
55
|
.from(moduleConfigs)
|
|
56
|
-
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, '
|
|
56
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'target_ip')))
|
|
57
57
|
.get();
|
|
58
58
|
|
|
59
59
|
// If both exist, no recovery needed
|
|
@@ -104,7 +104,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// Store in module_configs (recovery)
|
|
107
|
-
log.warn(' Recovering vmid and
|
|
107
|
+
log.warn(' Recovering vmid and target_ip from Terraform state...');
|
|
108
108
|
|
|
109
109
|
if (!existingVmid) {
|
|
110
110
|
await db
|
|
@@ -126,7 +126,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
126
126
|
.insert(moduleConfigs)
|
|
127
127
|
.values({
|
|
128
128
|
moduleId,
|
|
129
|
-
key: '
|
|
129
|
+
key: 'target_ip',
|
|
130
130
|
value: containerIp,
|
|
131
131
|
})
|
|
132
132
|
.onConflictDoUpdate({
|
|
@@ -136,5 +136,5 @@ export async function ensureProxmoxConfigFromState(
|
|
|
136
136
|
.run();
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
log.success(` Recovered: vmid=${vmid},
|
|
139
|
+
log.success(` Recovered: vmid=${vmid}, target_ip=${containerIp}`);
|
|
140
140
|
}
|
|
@@ -101,7 +101,7 @@ describe('ssh-key-manager', () => {
|
|
|
101
101
|
|
|
102
102
|
const keyPath = await writeTemporarySshKey(machine.id);
|
|
103
103
|
|
|
104
|
-
expect(keyPath).toContain('
|
|
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(
|
|
17
|
+
return join(tmpdir(), 'celilo-ansible-keys');
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|