@celilo/cli 2.3.0 → 3.0.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 (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. package/tsconfig.json +2 -13
@@ -15,7 +15,7 @@ import { createHookStores } from '../hooks/hook-store';
15
15
  import { loadHookConfigMap } from '../hooks/load-hook-config';
16
16
  import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
17
17
  import type { HookLogger, HookResult } from '../hooks/types';
18
- import type { ModuleManifest } from '../manifest/schema';
18
+ import type { HealthWaiver, ModuleManifest } from '../manifest/schema';
19
19
  import { decryptSecret } from '../secrets/encryption';
20
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
21
  import { controlPlaneHealthChecks } from './control-plane-health';
@@ -51,12 +51,26 @@ export interface HealthCheckResult {
51
51
  * watching to re-run the check by hand.
52
52
  */
53
53
  artifactPaths?: string[];
54
+ /**
55
+ * The module's recorded health waiver, if its manifest declares one
56
+ * (openspec/changes/health-waiver-mechanism). It does not change this
57
+ * result's severity — an unmeasured module stays unmeasured — it
58
+ * annotates the finding with who decided and why.
59
+ */
60
+ waiver?: HealthWaiver;
54
61
  }
55
62
 
56
63
  export interface HealthCheckOptions {
57
64
  debug?: boolean;
58
65
  noInteractive?: boolean;
59
66
  onProgress?: (msg: string) => void;
67
+ /**
68
+ * Presentation-only silence: no gauge and no plain stdout lines, hook
69
+ * output captured instead. Unlike `unattended` this does NOT touch the
70
+ * state-transition guard — a `--json` command still runs real checks
71
+ * and still moves lifecycle state (celilo#1362).
72
+ */
73
+ quiet?: boolean;
60
74
  /**
61
75
  * Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part
62
76
  * that matters beyond cosmetics — the module's lifecycle state is NOT
@@ -105,6 +119,15 @@ function createHealthCheckPresentation(
105
119
  return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
106
120
  }
107
121
 
122
+ // Non-interactive stdout (redirected, piped, or an explicit --json
123
+ // caller): stdout is a data channel here (celilo#699), so neither the
124
+ // gauge's frames nor the gauge logger's plain lines may reach it.
125
+ // Capture everything instead (celilo#1362). State transitions still
126
+ // happen — only the presentation goes quiet.
127
+ if (options.quiet || !process.stdout.isTTY) {
128
+ return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
129
+ }
130
+
108
131
  const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive });
109
132
  gauge.start();
110
133
  return {
@@ -178,7 +201,7 @@ export async function runModuleHealthCheck(
178
201
  const hookDef = manifest.hooks?.health_check;
179
202
 
180
203
  if (!hookDef) {
181
- return { moduleId, status: 'no-checks', checks: [] };
204
+ return { moduleId, status: 'no-checks', checks: [], waiver: manifest.health_waiver };
182
205
  }
183
206
 
184
207
  // Build config + secrets for hook context. The config map shape
@@ -245,7 +268,15 @@ export async function runModuleHealthCheck(
245
268
  };
246
269
 
247
270
  const checks = outputs.checks || [];
248
- return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
271
+ return finishHealthCheck(
272
+ moduleId,
273
+ module.state,
274
+ checks,
275
+ db,
276
+ options,
277
+ hookResult.artifactPaths,
278
+ manifest.health_waiver,
279
+ );
249
280
  }
250
281
 
251
282
  /**
@@ -277,6 +308,7 @@ function finishHealthCheck(
277
308
  db: DbClient,
278
309
  options: HealthCheckOptions,
279
310
  artifactPaths?: string[],
311
+ waiver?: HealthWaiver,
280
312
  ): HealthCheckResult {
281
313
  const status = deriveHealthStatus(checks);
282
314
 
@@ -298,7 +330,13 @@ function finishHealthCheck(
298
330
  .run();
299
331
  }
300
332
 
301
- return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
333
+ return {
334
+ moduleId,
335
+ status,
336
+ checks,
337
+ ...(artifactPaths ? { artifactPaths } : {}),
338
+ ...(waiver ? { waiver } : {}),
339
+ };
302
340
  }
303
341
 
304
342
  /**
@@ -343,7 +381,7 @@ async function mapConcurrent<T, R>(
343
381
  */
344
382
  export async function runAllHealthChecks(
345
383
  db: DbClient,
346
- options: { debug?: boolean; onProgress?: (msg: string) => void } = {},
384
+ options: { debug?: boolean; onProgress?: (msg: string) => void; quiet?: boolean } = {},
347
385
  ): Promise<HealthCheckResult[]> {
348
386
  const eligible = db
349
387
  .select()
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isPreservedGeneratedPath, pruneGeneratedProject } from './module-deploy';
5
+
6
+ const ROOT = '/tmp/celilo-prune-test';
7
+ const GENERATED = join(ROOT, 'generated');
8
+
9
+ /**
10
+ * Mirrors the layout observed on celilo-mgr at 2026-09-16T00:51Z:
11
+ * `generated/{ansible,deploy.log,terraform}` with state inside terraform/.
12
+ * Derived from the real tree rather than invented — a hand-written mirror gives
13
+ * confident results about a world that does not exist.
14
+ */
15
+ function buildGeneratedTree(): void {
16
+ mkdirSync(join(GENERATED, 'ansible', 'roles'), { recursive: true });
17
+ mkdirSync(join(GENERATED, 'terraform', '.terraform', 'providers'), { recursive: true });
18
+
19
+ writeFileSync(join(GENERATED, 'deploy.log'), 'deploy output');
20
+ writeFileSync(join(GENERATED, 'ansible', 'secrets.yml'), 'vault-encrypted secrets');
21
+ writeFileSync(join(GENERATED, 'ansible', 'roles', 'task.yml'), 'rendered task');
22
+ writeFileSync(join(GENERATED, 'terraform', 'main.tf'), 'rendered terraform');
23
+ writeFileSync(join(GENERATED, 'terraform', 'terraform.tfvars'), 'rendered vars');
24
+ writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), '{"lxc":207}');
25
+ writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'), '{"lxc":207,"old":1}');
26
+ writeFileSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'), 'provider lock');
27
+ writeFileSync(join(GENERATED, 'terraform', '.terraform', 'providers', 'bin'), 'huge binary');
28
+ }
29
+
30
+ beforeEach(() => {
31
+ rmSync(ROOT, { recursive: true, force: true });
32
+ buildGeneratedTree();
33
+ });
34
+
35
+ afterEach(() => {
36
+ rmSync(ROOT, { recursive: true, force: true });
37
+ });
38
+
39
+ describe('pruneGeneratedProject', () => {
40
+ // The recurrence gate for celilo#1374's root cause. Deleting this file is
41
+ // what armed every container module for destruction on its next deploy.
42
+ test('keeps terraform state and its lock', () => {
43
+ pruneGeneratedProject(GENERATED);
44
+
45
+ expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate'))).toBe(true);
46
+ expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'))).toBe(true);
47
+ expect(existsSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'))).toBe(true);
48
+ // Intact, not merely present.
49
+ expect(readFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), 'utf-8')).toBe(
50
+ '{"lxc":207}',
51
+ );
52
+ });
53
+
54
+ // D4's actual concern. If this ever passes, the fix has quietly become
55
+ // "persist the rendered project", which D4 refused for good reasons.
56
+ test('still deletes every rendered template and secret', () => {
57
+ pruneGeneratedProject(GENERATED);
58
+
59
+ expect(existsSync(join(GENERATED, 'ansible'))).toBe(false);
60
+ expect(existsSync(join(GENERATED, 'deploy.log'))).toBe(false);
61
+ expect(existsSync(join(GENERATED, 'terraform', 'main.tf'))).toBe(false);
62
+ expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfvars'))).toBe(false);
63
+ });
64
+
65
+ // ~19 MB per provider, rebuilt by `terraform init`; excluded from the backup
66
+ // envelope for the same reason (cross-module-read.ts).
67
+ test('drops the provider cache', () => {
68
+ pruneGeneratedProject(GENERATED);
69
+
70
+ expect(existsSync(join(GENERATED, 'terraform', '.terraform'))).toBe(false);
71
+ });
72
+
73
+ // A deploy that failed before rendering never made one.
74
+ test('is a no-op when generated/ is absent', () => {
75
+ rmSync(GENERATED, { recursive: true, force: true });
76
+
77
+ expect(() => pruneGeneratedProject(GENERATED)).not.toThrow();
78
+ });
79
+ });
80
+
81
+ describe('isPreservedGeneratedPath', () => {
82
+ test('preserves only state files directly under terraform/', () => {
83
+ expect(isPreservedGeneratedPath('terraform/terraform.tfstate')).toBe(true);
84
+ expect(isPreservedGeneratedPath('terraform/main.tf')).toBe(false);
85
+ expect(isPreservedGeneratedPath('ansible/terraform.tfstate')).toBe(false);
86
+ // A nested lookalike must not smuggle a whole tree through.
87
+ expect(isPreservedGeneratedPath('terraform/nested/terraform.tfstate')).toBe(false);
88
+ });
89
+ });
@@ -4,7 +4,7 @@
4
4
  * Coordinates full module deployment workflow across all phases
5
5
  */
6
6
 
7
- import { rmSync } from 'node:fs';
7
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { ProgressDisplay } from '@celilo/cli-display';
10
10
  import { and, eq } from 'drizzle-orm';
@@ -337,6 +337,61 @@ async function backfillArrivedProvider(moduleId: string, db: DbClient): Promise<
337
337
  * alerting, etc. — can react. Bus emit failures are best-effort and
338
338
  * never affect the deploy outcome.
339
339
  */
340
+ /**
341
+ * Files under `generated/terraform/` that outlive the deploy.
342
+ *
343
+ * D4 (control-plane-stops-building-modules) deletes `generated/` because it
344
+ * holds RENDERED templates and secrets — a tree nothing can validate and what a
345
+ * later deploy would wrongly ship. Terraform state is neither. It is the record
346
+ * of what celilo actually BUILT, and `templates/generator.ts` calls it
347
+ * "celilo's authoritative record of placement (ISS-0090)".
348
+ *
349
+ * Deleting it meant the next deploy started from empty state, failed to create
350
+ * a container that already existed, auto-imported it, and planned a REPLACE —
351
+ * destroying the container it was asked to converge (celilo#1374, which
352
+ * destroyed signal's LXC and its signal-cli device identity).
353
+ *
354
+ * `.terraform/` is NOT preserved: it is the provider binary cache, ~19 MB per
355
+ * provider, rebuilt by `terraform init`. `cross-module-read.ts` excludes it from
356
+ * the backup envelope for the same reason.
357
+ */
358
+ export const PRESERVED_GENERATED_FILES = [
359
+ 'terraform.tfstate',
360
+ 'terraform.tfstate.backup',
361
+ '.terraform.lock.hcl',
362
+ ];
363
+
364
+ /** True for the paths, relative to `generated/`, that survive the cleanup. */
365
+ export function isPreservedGeneratedPath(relativePath: string): boolean {
366
+ const [dir, file, ...rest] = relativePath.split('/');
367
+ if (dir !== 'terraform' || rest.length > 0) return false;
368
+ return file !== undefined && PRESERVED_GENERATED_FILES.includes(file);
369
+ }
370
+
371
+ /**
372
+ * Delete the rendered project but keep terraform's record of what exists.
373
+ * Absent `generated/` is not an error — a deploy that failed early never made
374
+ * one.
375
+ */
376
+ export function pruneGeneratedProject(generatedPath: string): void {
377
+ if (!existsSync(generatedPath)) return;
378
+
379
+ for (const entry of readdirSync(generatedPath, { withFileTypes: true })) {
380
+ if (entry.name !== 'terraform') {
381
+ rmSync(join(generatedPath, entry.name), { recursive: true, force: true });
382
+ }
383
+ }
384
+
385
+ const terraformDir = join(generatedPath, 'terraform');
386
+ if (!existsSync(terraformDir)) return;
387
+
388
+ for (const entry of readdirSync(terraformDir, { withFileTypes: true })) {
389
+ if (!isPreservedGeneratedPath(`terraform/${entry.name}`)) {
390
+ rmSync(join(terraformDir, entry.name), { recursive: true, force: true });
391
+ }
392
+ }
393
+ }
394
+
340
395
  export async function deployModule(
341
396
  moduleId: string,
342
397
  db: DbClient,
@@ -392,8 +447,10 @@ export async function deployModule(
392
447
  if (deployed) {
393
448
  const generatedPath = `${deployed.sourcePath}/generated`;
394
449
  try {
395
- rmSync(generatedPath, { recursive: true, force: true });
396
- log.info(`Removed generated/ for '${moduleId}' (ephemeral by design; --keep retains it)`);
450
+ pruneGeneratedProject(generatedPath);
451
+ log.info(
452
+ `Removed generated/ for '${moduleId}' (terraform state preserved; --keep retains all)`,
453
+ );
397
454
  } catch (cleanupError) {
398
455
  const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
399
456
  log.warn(`Failed to remove generated/ for '${moduleId}': ${msg}`);
@@ -907,48 +964,10 @@ async function deployModuleImpl(
907
964
  };
908
965
  }
909
966
 
910
- // Store hook outputs as secrets (e.g., refresh_token from OAuth flow)
911
- if (Object.keys(hookResult.outputs).length > 0) {
912
- const { secrets: secretsTable } = await import('../db/schema');
913
- const { encryptSecret } = await import('../secrets/encryption');
914
- const { and: andOp, eq: eqOp } = await import('drizzle-orm');
915
- for (const [key, value] of Object.entries(hookResult.outputs)) {
916
- if (typeof value === 'string') {
917
- const masterKeyForOutputs = await getOrCreateMasterKey();
918
- const encrypted = encryptSecret(value, masterKeyForOutputs);
919
-
920
- // Check if secret already exists (no unique constraint on module_id+name)
921
- const existing = db
922
- .select()
923
- .from(secretsTable)
924
- .where(andOp(eqOp(secretsTable.moduleId, moduleId), eqOp(secretsTable.name, key)))
925
- .get();
926
-
927
- if (existing) {
928
- db.update(secretsTable)
929
- .set({
930
- encryptedValue: encrypted.encryptedValue,
931
- iv: encrypted.iv,
932
- authTag: encrypted.authTag,
933
- updatedAt: new Date(),
934
- })
935
- .where(eqOp(secretsTable.id, existing.id))
936
- .run();
937
- } else {
938
- db.insert(secretsTable)
939
- .values({
940
- moduleId,
941
- name: key,
942
- encryptedValue: encrypted.encryptedValue,
943
- iv: encrypted.iv,
944
- authTag: encrypted.authTag,
945
- })
946
- .run();
947
- }
948
- log.success(`Stored hook output: ${key}`);
949
- }
950
- }
951
- }
967
+ // Hook return values are no longer persisted (hook-owned-state D5):
968
+ // a hook that needs to persist state does so explicitly through the
969
+ // `context.config` / `context.secrets` accessors inside its own
970
+ // process. Nothing here sweeps `hookResult.outputs` into secrets.
952
971
 
953
972
  gauge.stop(true);
954
973
  } catch (error) {
@@ -1352,45 +1371,11 @@ async function deployModuleImpl(
1352
1371
  };
1353
1372
  }
1354
1373
 
1355
- // Store hook outputs as secrets on the PROVIDER module
1356
- if (Object.keys(hookResult.outputs).length > 0) {
1357
- const { secrets: st } = await import('../db/schema');
1358
- const { encryptSecret } = await import('../secrets/encryption');
1359
- for (const [key, value] of Object.entries(hookResult.outputs)) {
1360
- if (typeof value === 'string') {
1361
- const mk = await getOrCreateMasterKey();
1362
- const encrypted = encryptSecret(value, mk);
1363
- const existing = db
1364
- .select()
1365
- .from(st)
1366
- .where(and(eq(st.moduleId, capRecord.moduleId), eq(st.name, key)))
1367
- .get();
1368
-
1369
- if (existing) {
1370
- db.update(st)
1371
- .set({
1372
- encryptedValue: encrypted.encryptedValue,
1373
- iv: encrypted.iv,
1374
- authTag: encrypted.authTag,
1375
- updatedAt: new Date(),
1376
- })
1377
- .where(eq(st.id, existing.id))
1378
- .run();
1379
- } else {
1380
- db.insert(st)
1381
- .values({
1382
- moduleId: capRecord.moduleId,
1383
- name: key,
1384
- encryptedValue: encrypted.encryptedValue,
1385
- iv: encrypted.iv,
1386
- authTag: encrypted.authTag,
1387
- })
1388
- .run();
1389
- }
1390
- log.success(`Stored ${capRecord.moduleId} hook output: ${key}`);
1391
- }
1392
- }
1393
- }
1374
+ // The return-value channel is gone (hook-owned-state D5/D3): hook
1375
+ // outputs are no longer persisted anywhere. A provider secret the
1376
+ // framework itself must record goes through the named
1377
+ // `recordProviderSecret` path (secrets/storage.ts) — never through a
1378
+ // hook return value.
1394
1379
 
1395
1380
  gauge.stop(true);
1396
1381
  }
@@ -1678,47 +1663,9 @@ async function deployModuleImpl(
1678
1663
  };
1679
1664
  }
1680
1665
 
1681
- // Persist on_install string outputs as secrets on this module — same
1682
- // as the validate_config / container_created paths above. caddy relies
1683
- // on this to publish its firewall external IP (`public_ip`) so
1684
- // public_web.register_route can point a new hostname's public A record
1685
- // at it (M1 / #328).
1686
- if (Object.keys(installResult.outputs).length > 0) {
1687
- const { secrets: st } = await import('../db/schema');
1688
- const { encryptSecret } = await import('../secrets/encryption');
1689
- const mk = await getOrCreateMasterKey();
1690
- for (const [key, value] of Object.entries(installResult.outputs)) {
1691
- if (typeof value !== 'string') continue;
1692
- const encrypted = encryptSecret(value, mk);
1693
- const existing = db
1694
- .select()
1695
- .from(st)
1696
- .where(and(eq(st.moduleId, moduleId), eq(st.name, key)))
1697
- .get();
1698
- if (existing) {
1699
- db.update(st)
1700
- .set({
1701
- encryptedValue: encrypted.encryptedValue,
1702
- iv: encrypted.iv,
1703
- authTag: encrypted.authTag,
1704
- updatedAt: new Date(),
1705
- })
1706
- .where(eq(st.id, existing.id))
1707
- .run();
1708
- } else {
1709
- db.insert(st)
1710
- .values({
1711
- moduleId,
1712
- name: key,
1713
- encryptedValue: encrypted.encryptedValue,
1714
- iv: encrypted.iv,
1715
- authTag: encrypted.authTag,
1716
- })
1717
- .run();
1718
- }
1719
- log.success(`Stored hook output: ${key}`);
1720
- }
1721
- }
1666
+ // Hook return values are no longer persisted (hook-owned-state D5).
1667
+ // caddy's `public_ip` moved to `context.config.set` + a config read
1668
+ // (task 5.1); no other hook may rely on its return reaching the store.
1722
1669
  }
1723
1670
 
1724
1671
  // Transition to INSTALLED; see the first transition for errorMessage (celilo#1363).
@@ -8,7 +8,7 @@
8
8
  * committed types file is not regenerated, this test fails.
9
9
  *
10
10
  * Scope: `modules/<id>/manifest.yml` at the repo root. Archived
11
- * modules (`modules/archive/**`) and scratch extract dirs
11
+ * modules (`modules/__archive__/**`) and scratch extract dirs
12
12
  * (`modules/.tmp-*`) are excluded — they are not active modules.
13
13
  */
14
14
 
@@ -14,11 +14,15 @@ describe('validateCapabilityVersions', () => {
14
14
  });
15
15
 
16
16
  test('no errors when provides matches the runtime registry exactly', () => {
17
+ // Read from the registry, not pinned to a literal (see the comment on the
18
+ // "differs from runtime" test below — the registry moves, the test is
19
+ // about matching it).
20
+ const runtime = CAPABILITY_CONTRACT_VERSIONS.public_web;
17
21
  expect(
18
22
  validateCapabilityVersions({
19
23
  id: 'caddy',
20
24
  version: '3.0.0',
21
- provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
25
+ provides: { capabilities: [{ name: 'public_web', version: runtime }] },
22
26
  }),
23
27
  ).toEqual([]);
24
28
  });
@@ -40,10 +44,17 @@ describe('validateCapabilityVersions', () => {
40
44
  });
41
45
 
42
46
  test('error when provides[X].version is newer than runtime', () => {
47
+ // One minor above the registry is newer without being a different major,
48
+ // which is what this case tests. Computed from the registry so the test
49
+ // stays a behaviour test as the registry moves.
50
+ const [maj, min, pat] = CAPABILITY_CONTRACT_VERSIONS.public_web
51
+ .split('.')
52
+ .map((n) => Number.parseInt(n, 10));
53
+ const tooNew = `${maj}.${(min ?? 0) + 1}.${pat ?? 0}`;
43
54
  const errors = validateCapabilityVersions({
44
55
  id: 'caddy',
45
56
  version: '4.0.0',
46
- provides: { capabilities: [{ name: 'public_web', version: '4.0.0' }] },
57
+ provides: { capabilities: [{ name: 'public_web', version: tooNew }] },
47
58
  });
48
59
  expect(errors).toHaveLength(1);
49
60
  expect(errors[0]).toContain('provides[public_web]');
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { planAfterImportVerdict, validateTerraformPlanSafety } from './terraform-safety';
3
+
4
+ /**
5
+ * The plan terraform actually produced on celilo-mgr at 2026-09-16T00:04Z,
6
+ * after `module deploy signal` imported LXC 207 and re-planned. Abridged to the
7
+ * lines the parser reads, and kept verbatim otherwise: a fixture written from
8
+ * memory of the format would pass while the product still destroyed the box.
9
+ */
10
+ const POST_IMPORT_REPLACEMENT_PLAN = `
11
+ proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
12
+
13
+ Terraform used the selected providers to generate the following execution
14
+ plan. Resource actions are indicated with the following symbols:
15
+ -/+ destroy and then create replacement
16
+
17
+ Terraform will perform the following actions:
18
+
19
+ # proxmox_lxc.signal must be replaced
20
+ -/+ resource "proxmox_lxc" "signal" {
21
+ + ostemplate = "datacenter:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst" # forces replacement
22
+ ~ rootfs {
23
+ + storage = "datacenter" # forces replacement
24
+ }
25
+ }
26
+
27
+ Plan: 1 to add, 0 to change, 1 to destroy.
28
+ `;
29
+
30
+ const CLEAN_PLAN = `
31
+ proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
32
+
33
+ No changes. Your infrastructure matches the configuration.
34
+ `;
35
+
36
+ const IN_PLACE_UPDATE_PLAN = `
37
+ # proxmox_lxc.signal will be updated in-place
38
+ ~ resource "proxmox_lxc" "signal" {
39
+ ~ memory = 1024 -> 2048
40
+ }
41
+
42
+ Plan: 0 to add, 1 to change, 0 to destroy.
43
+ `;
44
+
45
+ describe('planAfterImportVerdict', () => {
46
+ // THE recurrence gate for celilo#1374. This exact plan was applied without
47
+ // validation and destroyed signal's container on a converged fleet. If this
48
+ // test ever passes the plan through, the defect is back.
49
+ test('refuses the replacement plan an import produces', () => {
50
+ const verdict = planAfterImportVerdict(2, POST_IMPORT_REPLACEMENT_PLAN);
51
+
52
+ expect(verdict.action).toBe('refuse');
53
+ if (verdict.action !== 'refuse') throw new Error('unreachable');
54
+ expect(verdict.error).toContain('after importing an existing resource');
55
+ });
56
+
57
+ // The outcome the recovery path is FOR: the import reconciled state with
58
+ // reality and there is nothing left to apply. Adopt, don't apply.
59
+ test('applies nothing when the post-import plan is clean', () => {
60
+ expect(planAfterImportVerdict(0, CLEAN_PLAN)).toEqual({ action: 'done' });
61
+ });
62
+
63
+ // A resize must still go through — refusing everything would be a different
64
+ // bug wearing the same fix (terraform-safety.ts rule 2, ISS-0055).
65
+ test('lets an in-place update through', () => {
66
+ expect(planAfterImportVerdict(2, IN_PLACE_UPDATE_PLAN)).toEqual({ action: 'apply' });
67
+ });
68
+
69
+ // An unreadable plan is not evidence of safety (D7).
70
+ test('refuses when the plan itself failed', () => {
71
+ const verdict = planAfterImportVerdict(1, 'Error: failed to refresh state');
72
+
73
+ expect(verdict.action).toBe('refuse');
74
+ });
75
+ });
76
+
77
+ describe('validateTerraformPlanSafety', () => {
78
+ // Characterises what was already true: the validator would have caught this.
79
+ // The defect was never a missing rule, it was a plan that never reached one.
80
+ test('already refuses a replacement — it was simply never asked', () => {
81
+ expect(validateTerraformPlanSafety(POST_IMPORT_REPLACEMENT_PLAN).safe).toBe(false);
82
+ });
83
+ });
@@ -178,3 +178,56 @@ function formatUnsafeOperationError(action: TerraformAction): string {
178
178
 
179
179
  return lines.join('\n');
180
180
  }
181
+
182
+ /** What the deploy should do with the plan computed AFTER an auto-import. */
183
+ export type ImportRetryVerdict =
184
+ | { action: 'done' }
185
+ | { action: 'apply' }
186
+ | { action: 'refuse'; error: string };
187
+
188
+ /**
189
+ * Decide whether the apply retried after an auto-import may proceed.
190
+ *
191
+ * An import REPLACES the plan that `deployTerraform` validated before its first
192
+ * apply. That plan described a create; the plan computed after the import can
193
+ * describe a REPLACE, because `ostemplate` and `rootfs[0].storage` do not
194
+ * round-trip through an import and each forces one. Nothing re-validated it, so
195
+ * the retry applied a destroy that `validateTerraformPlanSafety` exists to
196
+ * forbid — and a redeploy destroyed signal's LXC on a converged fleet
197
+ * (celilo#1374). The gate was present; the import walked around it.
198
+ *
199
+ * Pure (Rule 10) so the refusal is testable without terraform: the caller does
200
+ * the I/O and hands the exit code and output here.
201
+ */
202
+ export function planAfterImportVerdict(exitCode: number, planOutput: string): ImportRetryVerdict {
203
+ // 0 = no changes. The import alone reconciled state with reality, which is
204
+ // the outcome we want: adopt the resource and apply nothing.
205
+ if (exitCode === 0) {
206
+ return { action: 'done' };
207
+ }
208
+
209
+ // 2 = changes pending. Anything else is an error, and an unreadable plan is
210
+ // never a licence to apply (D7: no measurement is not "no drift").
211
+ if (exitCode !== 2) {
212
+ return {
213
+ action: 'refuse',
214
+ error: `Refusing to apply after import: terraform plan failed (exit ${exitCode}).\n${planOutput.slice(0, 500)}`,
215
+ };
216
+ }
217
+
218
+ const safety = validateTerraformPlanSafety(planOutput);
219
+ if (!safety.safe) {
220
+ return {
221
+ action: 'refuse',
222
+ error: [
223
+ 'Refusing to apply the plan computed after importing an existing resource.',
224
+ 'The import adopted infrastructure that already exists, and applying this',
225
+ 'plan would destroy it rather than adopt it.',
226
+ '',
227
+ safety.error ?? 'unsafe plan',
228
+ ].join('\n'),
229
+ };
230
+ }
231
+
232
+ return { action: 'apply' };
233
+ }
@@ -90,7 +90,7 @@ const cleanAudit: AuditDeps = {
90
90
  machinesReachable: { results: [] },
91
91
  diskSpace: { results: [] },
92
92
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
93
- trustedSources: { firewalls: [] },
93
+ trustedSources: { firewalls: [], unreachableFirewalls: [] },
94
94
  publicDns: { records: [], probe: unusedPublicDnsProbe },
95
95
  };
96
96
 
package/tsconfig.json CHANGED
@@ -1,21 +1,10 @@
1
1
  {
2
+ "extends": "../../tsconfig.base.json",
2
3
  "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
4
  "lib": ["ESNext"],
6
- "moduleResolution": "bundler",
7
5
  "jsx": "react-jsx",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "resolveJsonModule": true,
13
- "isolatedModules": true,
14
- "types": ["bun-types"],
15
6
  "paths": {
16
7
  "@/*": ["./src/*"]
17
8
  }
18
- },
19
- "include": ["src/**/*", "test-integration/**/*"],
20
- "exclude": ["node_modules"]
9
+ }
21
10
  }