@celilo/cli 2.1.0 → 2.2.1

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 (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -10,8 +10,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
- import { modules } from '../../db/schema';
13
+ import { moduleConfigs, modules } from '../../db/schema';
14
14
  import { resolveDeployPosture } from '../../services/deploy-posture';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import {
16
17
  FRAMEWORK_CONFIG_KEYS,
17
18
  handleModuleConfigGet,
@@ -41,6 +42,7 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
41
42
  { name: 'vmid', type: 'integer', source: 'infrastructure' },
42
43
  { name: 'target_node', type: 'string', source: 'infrastructure' },
43
44
  { name: 'app_port', type: 'integer' },
45
+ { name: 'public_ip', type: 'string', source: 'hook' },
44
46
  ],
45
47
  },
46
48
  },
@@ -50,7 +52,7 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
50
52
 
51
53
  afterEach(() => {
52
54
  rmSync(tempDir, { recursive: true, force: true });
53
- process.env.CELILO_DB_PATH = undefined;
55
+ resetTestDbPath();
54
56
  });
55
57
 
56
58
  test('rejects an infrastructure-managed key (no silent accept-then-override)', async () => {
@@ -79,6 +81,24 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
79
81
  expect(result.success).toBe(true);
80
82
  });
81
83
 
84
+ // Hook-owned-state task 2.4 (design D2/D6): a `source: hook` variable is
85
+ // written ONLY by the module's own hook via `context.config.set`. A hand set
86
+ // value would be meaningless — the next hook run overwrites it — so the
87
+ // command refuses rather than reporting success over nothing.
88
+ test('rejects a hook-owned key and points at the accessor', async () => {
89
+ const result = await handleModuleConfigSet(['testmod', 'public_ip', '203.0.113.7']);
90
+ expect(result.success).toBe(false);
91
+ if (!result.success) {
92
+ expect(result.error).toContain('source: hook');
93
+ expect(result.error).toContain('not operator-settable');
94
+ expect(result.error).toContain('context.config.set');
95
+ }
96
+ // And no row may exist — a refusal that leaves the row behind is the
97
+ // accept-then-override bug in a different coat.
98
+ const rows = db.select().from(moduleConfigs).all();
99
+ expect(rows).toEqual([]);
100
+ });
101
+
82
102
  test('still rejects an undeclared key, and omits infra keys from the valid-keys hint', async () => {
83
103
  const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
84
104
  expect(result.success).toBe(false);
@@ -14,7 +14,7 @@ import type { CommandResult } from '../types';
14
14
  * Handle module deploy command
15
15
  *
16
16
  * Usage:
17
- * celilo module deploy <module-id> [--debug] [--preflight] [--verbose]
17
+ * celilo module deploy <module-id> [--debug] [--preflight] [--verbose] [--keep]
18
18
  *
19
19
  * Note: there is no `--no-interactive` flag. The deploy interview
20
20
  * runs through the bus event system; automation answers via a
@@ -59,8 +59,14 @@ export async function handleModuleDeploy(
59
59
  const debug = hasFlag(flags, 'debug');
60
60
  const verbose = hasFlag(flags, 'verbose');
61
61
  const stopAfterInterview = hasFlag(flags, 'stop-after-interview');
62
+ const keepGeneratedProject = hasFlag(flags, 'keep');
62
63
 
63
- const result = await deployModule(moduleId, db, { debug, verbose, stopAfterInterview });
64
+ const result = await deployModule(moduleId, db, {
65
+ debug,
66
+ verbose,
67
+ stopAfterInterview,
68
+ keepGeneratedProject,
69
+ });
64
70
 
65
71
  if (!result.success) {
66
72
  return {
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ModuleManifest } from '../../manifest/schema';
6
+ import { missingBuildOutputs } from './module-generate';
7
+
8
+ const DECLARED = join('ansible', 'roles', 'fixture', 'files', 'binary');
9
+
10
+ function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
11
+ return {
12
+ celilo_contract: '1.0',
13
+ id: 'fixture',
14
+ name: 'Fixture',
15
+ version: '1.0.0',
16
+ description: 'fixture',
17
+ build: { command: 'true', artifacts },
18
+ } as ModuleManifest;
19
+ }
20
+
21
+ describe('missingBuildOutputs', () => {
22
+ let payload: string;
23
+
24
+ beforeEach(() => {
25
+ payload = mkdtempSync(join(tmpdir(), 'celilo-missing-outputs-'));
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(payload, { recursive: true, force: true });
30
+ });
31
+
32
+ test('names the declared output the payload is missing', () => {
33
+ expect(missingBuildOutputs(payload, manifestWithArtifacts([DECLARED]))).toEqual([DECLARED]);
34
+ });
35
+
36
+ test('reports nothing when the payload carries its declared outputs', () => {
37
+ mkdirSync(join(payload, 'ansible', 'roles', 'fixture', 'files'), { recursive: true });
38
+ writeFileSync(join(payload, DECLARED), 'built-bytes');
39
+ expect(missingBuildOutputs(payload, manifestWithArtifacts([DECLARED]))).toEqual([]);
40
+ });
41
+
42
+ test('reports nothing for a module with no build block', () => {
43
+ expect(
44
+ missingBuildOutputs(payload, {
45
+ celilo_contract: '1.0',
46
+ id: 'fixture',
47
+ name: 'Fixture',
48
+ version: '1.0.0',
49
+ description: 'fixture',
50
+ } as ModuleManifest),
51
+ ).toEqual([]);
52
+ });
53
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
- import { resolve } from 'node:path';
6
+ import { join, resolve } from 'node:path';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import {
9
9
  promptForMissingCapabilitySecrets,
@@ -13,12 +13,25 @@ import { getDb } from '../../db/client';
13
13
  import { moduleConfigs, modules } from '../../db/schema';
14
14
  import type { ModuleManifest } from '../../manifest/schema';
15
15
  import { interviewForMissingSecrets, validateModuleSecrets } from '../../services/config-interview';
16
- import { getModuleBuildStatus, verifyArtifactsExist } from '../../services/module-build';
17
16
  import { generateTemplates } from '../../templates/generator';
18
17
  import { promptForMissingConfig } from '../interactive-config';
19
18
  import { getArg, getFlag, validateRequiredArgs } from '../parser';
20
19
  import type { CommandResult } from '../types';
21
20
 
21
+ /**
22
+ * Declared `build.artifacts` absent from the module's delivered payload.
23
+ *
24
+ * D1 (control-plane-stops-building-modules): generation consumes the payload.
25
+ * A declared output the payload does not carry is a publish defect, so it is
26
+ * reported to the caller and named, never repaired by running a build here.
27
+ * A fallback build is the shape that hid celilo#1307 for four months: the
28
+ * failure must fail loudly at the boundary, not be repaired downstream.
29
+ */
30
+ export function missingBuildOutputs(modulePath: string, manifest: ModuleManifest): string[] {
31
+ const artifacts = manifest.build?.artifacts ?? [];
32
+ return artifacts.filter((artifact) => !existsSync(join(modulePath, artifact)));
33
+ }
34
+
22
35
  /**
23
36
  * Handle module generate command
24
37
  *
@@ -71,31 +84,23 @@ export async function handleModuleGenerate(
71
84
  };
72
85
  }
73
86
 
74
- // Check if module requires build step
87
+ // D1 (control-plane-stops-building-modules): generation consumes the
88
+ // delivered payload. Whether this machine ever ran a build is a fact about
89
+ // its own history, not about the module in front of it — gating on it is
90
+ // what made celilo#1307 refuse generation on a production control plane
91
+ // whose payload carried the correct binaries one directory away.
75
92
  const manifest = module.manifestData as ModuleManifest;
76
- if (manifest.build) {
77
- const buildStatus = await getModuleBuildStatus(moduleId, db);
78
-
79
- if (!buildStatus || buildStatus.status !== 'success') {
80
- return {
81
- success: false,
82
- error:
83
- `Module ${moduleId} requires build step before generation.\n\n` +
84
- `Run: celilo module build ${moduleId}`,
85
- };
86
- }
87
-
88
- // Verify artifacts still exist
89
- if (buildStatus.artifacts.length > 0) {
90
- const artifactsValid = verifyArtifactsExist(buildStatus.artifacts);
91
-
92
- if (!artifactsValid) {
93
- return {
94
- success: false,
95
- error: `Build artifacts missing or invalid.\n\nRebuild: celilo module build ${moduleId}`,
96
- };
97
- }
98
- }
93
+ const missingOutputs = missingBuildOutputs(modulePath, manifest);
94
+ if (missingOutputs.length > 0) {
95
+ return {
96
+ success: false,
97
+ error: [
98
+ `Module payload is missing ${missingOutputs.length} declared build output(s):`,
99
+ ...missingOutputs.map((artifact) => ` ${artifact}`),
100
+ '',
101
+ "The payload must carry the module's build outputs; a control plane never builds. This is a publish defect: re-package the module and update it. Generation will not fall back to running a build.",
102
+ ].join('\n'),
103
+ };
99
104
  }
100
105
 
101
106
  const outputPathFlag = getFlag(flags, 'output', '');
@@ -12,6 +12,7 @@ import { closeDb, getDb } from '../../db/client';
12
12
  import { runMigrations } from '../../db/migrate';
13
13
  import { modules } from '../../db/schema';
14
14
  import type { BaseModuleAspect } from '../../manifest/schema';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import { handleAspectApprovalAfterImport } from './module-import';
16
17
 
17
18
  const aspect: BaseModuleAspect = {
@@ -61,7 +62,7 @@ describe('handleAspectApprovalAfterImport (non-interactive)', () => {
61
62
  afterEach(() => {
62
63
  setTTY(originalIsTTY);
63
64
  closeDb();
64
- process.env.CELILO_DB_PATH = undefined;
65
+ resetTestDbPath();
65
66
  try {
66
67
  rmSync(dir, { recursive: true, force: true });
67
68
  } catch {
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import type { IndexEntry } from '../../registry/client';
19
19
  import { RegistryClient } from '../../registry/client';
20
+ import { resetTestDbPath } from '../../test-utils/db-path';
20
21
  import { handlePublicRegistryImport } from './module-import';
21
22
 
22
23
  // handlePublicRegistryImport is not exported by default — re-exported at end of module-import.ts
@@ -42,7 +43,7 @@ afterEach(() => {
42
43
  getIndexSpy.mockRestore();
43
44
  downloadSpy.mockRestore();
44
45
  rmSync(tempDir, { recursive: true, force: true });
45
- process.env.CELILO_DB_PATH = undefined;
46
+ resetTestDbPath();
46
47
  process.env.CELILO_DATA_DIR = undefined;
47
48
  });
48
49
 
@@ -339,7 +339,7 @@ async function handlePublicRegistryImport(
339
339
  // Download the .netapp
340
340
  const tmpPath = join(tmpdir(), `${name}-${latest.vers}-${Date.now()}.netapp`);
341
341
  try {
342
- const pkgData = await client.download(name, latest.vers);
342
+ const pkgData = await client.download(name, latest.vers, latest.cksum);
343
343
  await Bun.write(tmpPath, pkgData);
344
344
  } catch (err) {
345
345
  return {
@@ -7,6 +7,7 @@ import { closeDb, getDb } from '../../db/client';
7
7
  import { runMigrations } from '../../db/migrate';
8
8
  import { moduleOperations } from '../../db/schema';
9
9
  import { ABANDONED_RELEASE_MESSAGE, OPERATION_TTL_MS } from '../../services/module-operations';
10
+ import { resetTestDbPath } from '../../test-utils/db-path';
10
11
  import { handleModuleOperations } from './module-operations';
11
12
 
12
13
  describe('celilo module operations', () => {
@@ -20,7 +21,7 @@ describe('celilo module operations', () => {
20
21
 
21
22
  afterEach(() => {
22
23
  closeDb();
23
- process.env.CELILO_DB_PATH = undefined;
24
+ resetTestDbPath();
24
25
  rmSync(dir, { recursive: true, force: true });
25
26
  });
26
27
 
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
15
  import { mkdir, rm, writeFile } from 'node:fs/promises';
16
16
  import { join } from 'node:path';
17
17
  import type { IndexEntry } from '../../registry/client';
18
+ import { resetTestDbPath } from '../../test-utils/db-path';
18
19
  import { handleModulePublish, resolveToken } from './module-publish';
19
20
 
20
21
  const TEST_DIR = `/tmp/test-module-publish-${Date.now()}`;
@@ -41,7 +42,6 @@ describe('handleModulePublish — validation', () => {
41
42
  // fallback in resolveToken() may read a real publish token and the test
42
43
  // will skip the validation path it's trying to exercise.
43
44
  const origToken = process.env.CELILO_PUBLISH_TOKEN;
44
- const origDbPath = process.env.CELILO_DB_PATH;
45
45
  const origDataDir = process.env.CELILO_DATA_DIR;
46
46
  process.env.CELILO_PUBLISH_TOKEN = undefined;
47
47
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-no-token-${Date.now()}.db`;
@@ -52,8 +52,7 @@ describe('handleModulePublish — validation', () => {
52
52
  if (!result.success) expect(result.error).toContain('Publish token required');
53
53
  } finally {
54
54
  if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken;
55
- if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
56
- else process.env.CELILO_DB_PATH = undefined;
55
+ resetTestDbPath();
57
56
  if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
58
57
  else process.env.CELILO_DATA_DIR = undefined;
59
58
  }
@@ -163,7 +162,6 @@ describe('revision auto-detection algorithm', () => {
163
162
  describe('resolveToken — precedence', () => {
164
163
  test('--token flag wins over env var and secret store', async () => {
165
164
  const origEnv = process.env.CELILO_PUBLISH_TOKEN;
166
- const origDbPath = process.env.CELILO_DB_PATH;
167
165
  process.env.CELILO_PUBLISH_TOKEN = 'env-token';
168
166
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`;
169
167
  try {
@@ -172,14 +170,12 @@ describe('resolveToken — precedence', () => {
172
170
  } finally {
173
171
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
174
172
  else process.env.CELILO_PUBLISH_TOKEN = undefined;
175
- if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
176
- else process.env.CELILO_DB_PATH = undefined;
173
+ resetTestDbPath();
177
174
  }
178
175
  });
179
176
 
180
177
  test('env var used when --token flag is empty', async () => {
181
178
  const origEnv = process.env.CELILO_PUBLISH_TOKEN;
182
- const origDbPath = process.env.CELILO_DB_PATH;
183
179
  process.env.CELILO_PUBLISH_TOKEN = 'env-token';
184
180
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`;
185
181
  try {
@@ -188,8 +184,7 @@ describe('resolveToken — precedence', () => {
188
184
  } finally {
189
185
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
190
186
  else process.env.CELILO_PUBLISH_TOKEN = undefined;
191
- if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
192
- else process.env.CELILO_DB_PATH = undefined;
187
+ resetTestDbPath();
193
188
  }
194
189
  });
195
190
 
@@ -197,7 +192,6 @@ describe('resolveToken — precedence', () => {
197
192
  // All three sources empty: secret-store fallback hits an isolated empty
198
193
  // DB and returns null gracefully.
199
194
  const origEnv = process.env.CELILO_PUBLISH_TOKEN;
200
- const origDbPath = process.env.CELILO_DB_PATH;
201
195
  const origDataDir = process.env.CELILO_DATA_DIR;
202
196
  process.env.CELILO_PUBLISH_TOKEN = undefined;
203
197
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`;
@@ -207,8 +201,7 @@ describe('resolveToken — precedence', () => {
207
201
  expect(t).toBe('');
208
202
  } finally {
209
203
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
210
- if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
211
- else process.env.CELILO_DB_PATH = undefined;
204
+ resetTestDbPath();
212
205
  if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
213
206
  else process.env.CELILO_DATA_DIR = undefined;
214
207
  }
@@ -18,8 +18,10 @@ import {
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join, relative } from 'node:path';
20
20
  import { eq } from 'drizzle-orm';
21
+ import { create as tarCreate } from 'tar';
21
22
  import { type DbClient, getDb } from '../../db/client';
22
23
  import { moduleIntegrity, modules } from '../../db/schema';
24
+ import { resetTestDbPath } from '../../test-utils/db-path';
23
25
  import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
24
26
 
25
27
  describe('classifyVersionChange', () => {
@@ -123,7 +125,7 @@ description: fixture
123
125
 
124
126
  afterEach(() => {
125
127
  rmSync(tempDir, { recursive: true, force: true });
126
- process.env.CELILO_DB_PATH = undefined;
128
+ resetTestDbPath();
127
129
  process.env.CELILO_ORIGINAL_CWD = undefined;
128
130
  });
129
131
 
@@ -312,7 +314,7 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
312
314
  afterEach(() => {
313
315
  server.stop(true);
314
316
  rmSync(tempDir, { recursive: true, force: true });
315
- process.env.CELILO_DB_PATH = undefined;
317
+ resetTestDbPath();
316
318
  process.env.CELILO_ORIGINAL_CWD = undefined;
317
319
  process.env.EVENT_BUS_DB = undefined;
318
320
  });
@@ -386,7 +388,7 @@ description: fixture
386
388
 
387
389
  afterEach(() => {
388
390
  rmSync(tempDir, { recursive: true, force: true });
389
- process.env.CELILO_DB_PATH = undefined;
391
+ resetTestDbPath();
390
392
  process.env.CELILO_ORIGINAL_CWD = undefined;
391
393
  });
392
394
 
@@ -498,7 +500,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
498
500
 
499
501
  afterEach(() => {
500
502
  rmSync(tempDir, { recursive: true, force: true });
501
- process.env.CELILO_DB_PATH = undefined;
503
+ resetTestDbPath();
502
504
  process.env.CELILO_ORIGINAL_CWD = undefined;
503
505
  });
504
506
 
@@ -556,3 +558,84 @@ describe('updateOne — an update that fails partway leaves the install untouche
556
558
  expect(mod?.version).toBe('1.0.0');
557
559
  });
558
560
  });
561
+
562
+ describe('updateOne — a .netapp package source', () => {
563
+ let tempDir: string;
564
+ let installedDir: string;
565
+ let db: DbClient;
566
+
567
+ beforeEach(() => {
568
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-netapp-'));
569
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
570
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
571
+
572
+ installedDir = join(tempDir, 'installed', 'testmod');
573
+ mkdirSync(installedDir, { recursive: true });
574
+ writeFileSync(
575
+ join(installedDir, 'manifest.yml'),
576
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
577
+ );
578
+
579
+ db = getDb();
580
+ db.insert(modules)
581
+ .values({
582
+ id: 'testmod',
583
+ name: 'Test Module',
584
+ sourcePath: installedDir,
585
+ version: '1.0.0+5',
586
+ manifestData: {
587
+ celilo_contract: '1.0',
588
+ id: 'testmod',
589
+ name: 'Test Module',
590
+ version: '1.0.0',
591
+ },
592
+ })
593
+ .run();
594
+ });
595
+
596
+ afterEach(() => {
597
+ rmSync(tempDir, { recursive: true, force: true });
598
+ resetTestDbPath();
599
+ process.env.CELILO_ORIGINAL_CWD = undefined;
600
+ });
601
+
602
+ // ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and
603
+ // `replaceInstalledModule` still reads the extracted package through
604
+ // `actualPath`, which IS the temp dir for a .netapp source. Every
605
+ // registry-driven upgrade died with `ENOENT ... scandir
606
+ // .tmp-module-extract/<ts>`. Directory sources never set tempDir, which
607
+ // is why the path-source tests above stayed green. This test drives the
608
+ // real extraction path to keep that ordering honest.
609
+ test('the staged swap reads the package before the temp dir is cleaned', async () => {
610
+ const srcDir = join(tempDir, 'pkg-src');
611
+ mkdirSync(srcDir, { recursive: true });
612
+ writeFileSync(
613
+ join(srcDir, 'manifest.yml'),
614
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
615
+ );
616
+ const pkgPath = join(tempDir, 'testmod-2.0.0.netapp');
617
+ await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
618
+
619
+ const extractRoot = join(process.cwd(), '.tmp-module-extract');
620
+ const extractCountBefore = existsSync(extractRoot) ? readdirSync(extractRoot).length : 0;
621
+
622
+ // Registry packages are pre-verified at publish time; updateOne gets
623
+ // skip-verify from the sweep, so no signature.sig is needed here.
624
+ const result = await updateOne(
625
+ pkgPath,
626
+ db,
627
+ { 'skip-verify': true },
628
+ { quiet: true, displayVersion: '2.0.0+7' },
629
+ );
630
+
631
+ expect(result.status).toBe('success');
632
+ if (result.status !== 'success') return;
633
+ expect(result.previousVersion).toBe('1.0.0+5');
634
+ expect(result.newVersion).toBe('2.0.0+7');
635
+ expect(readFileSync(join(installedDir, 'manifest.yml'), 'utf-8')).toContain('version: 2.0.0');
636
+
637
+ // The extraction scratch space is gone again once the swap has read it.
638
+ const extractCountAfter = existsSync(extractRoot) ? readdirSync(extractRoot).length : 0;
639
+ expect(extractCountAfter).toBe(extractCountBefore);
640
+ });
641
+ });
@@ -128,7 +128,13 @@ export async function fetchAndUpdate(
128
128
  ): Promise<UpdateOutcome> {
129
129
  const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
130
130
  try {
131
- const pkgData = await client.download(moduleId, version);
131
+ // The index entry carries the published sha256. Look it up here rather than
132
+ // thread it through five call sites, so a short download is caught as a bad
133
+ // transfer instead of surfacing later as "zlib: unexpected end of file".
134
+ // Absence is tolerated: an entry predating cksum still downloads.
135
+ const entries = await client.getIndex(moduleId).catch(() => []);
136
+ const cksum = entries.find((entry) => entry.vers === version)?.cksum;
137
+ const pkgData = await client.download(moduleId, version, cksum);
132
138
  await Bun.write(tmpPath, pkgData);
133
139
  } catch (err) {
134
140
  return {
@@ -410,9 +416,6 @@ export async function updateOne(
410
416
  ? readFileSync(packagedSignaturePath, 'utf-8').trim()
411
417
  : null;
412
418
 
413
- // Clean up temp dir if we extracted a .netapp
414
- if (tempDir) await cleanupTempDir(tempDir);
415
-
416
419
  // Remove what the new version dropped. `updateOne` only ever overlaid files,
417
420
  // so a hook script deleted in 1.1.0 stayed on the box and stayed runnable —
418
421
  // code celilo no longer believes it has installed, which is the same class of
@@ -440,6 +443,13 @@ export async function updateOne(
440
443
  // below it runs only once the files are really in place.
441
444
  replaceInstalledModule(actualPath, installedPath, pruneDropped);
442
445
 
446
+ // Clean up the extracted package. This waits until after the swap because
447
+ // `replaceInstalledModule` reads `actualPath`, which IS `tempDir` for a
448
+ // .netapp source — deleting first is what made every registry-driven
449
+ // upgrade die with `ENOENT ... scandir .tmp-module-extract/<ts>`
450
+ // (ce-h4of, a regression from the celilo#1008 staging swap).
451
+ if (tempDir) await cleanupTempDir(tempDir);
452
+
443
453
  // Record it. `updateOne` never touched this table, so the baseline stayed
444
454
  // frozen at the module's FIRST import no matter how many times it was
445
455
  // updated. That is why `module verify` reported the same violations whether
@@ -4,6 +4,7 @@ import {
4
4
  type PollCandidate,
5
5
  isPollInvocation,
6
6
  needsPreUpgradeBackup,
7
+ needsSystemStateSnapshot,
7
8
  pickAutoUpgrade,
8
9
  pickUpgradePolicy,
9
10
  selectPollTargets,
@@ -102,6 +103,20 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
102
103
  });
103
104
  });
104
105
 
106
+ // A 0.x minor/major bump can migrate the celilo DB schema; the system_state
107
+ // snapshot is what makes a later `apt install celilo=<old>` (or dpkg -i from
108
+ // the apt cache) a real rollback instead of a gamble. Patch/revision bumps
109
+ // don't migrate, so they stay fast.
110
+ describe('needsSystemStateSnapshot (safe posture snapshots the celilo DB before mutating)', () => {
111
+ test('safe posture (minor/major) requires the snapshot', () => {
112
+ expect(needsSystemStateSnapshot('safe')).toBe(true);
113
+ });
114
+
115
+ test('fast posture (patch/revision) skips the snapshot', () => {
116
+ expect(needsSystemStateSnapshot('fast')).toBe(false);
117
+ });
118
+ });
119
+
105
120
  describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifest)', () => {
106
121
  // The regression: the upgrade that FIRST adds on_backup must still back up.
107
122
  // The installed version lacked the hook; the target (passed here) adds it.
@@ -20,7 +20,8 @@ import { type DbClient, getDb } from '../../db/client';
20
20
  import { modules } from '../../db/schema';
21
21
  import type { ModuleManifest } from '../../manifest/schema';
22
22
  import { RegistryClient } from '../../registry/client';
23
- import { createModuleBackup } from '../../services/backup-create';
23
+ import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
24
+ import { getDefaultBackupStorage } from '../../services/backup-storage';
24
25
  import {
25
26
  type DeployPosture,
26
27
  type UpgradePolicy,
@@ -79,6 +80,47 @@ export function needsPreUpgradeBackup(
79
80
  return posture === 'safe' && Boolean(targetManifest.hooks?.on_backup);
80
81
  }
81
82
 
83
+ /**
84
+ * Pure (Rule 10): does this upgrade snapshot the celilo DB first? Safe posture
85
+ * means a minor/major bump, and a minor bump can migrate the DB schema — the
86
+ * snapshot is what makes a later package downgrade a real rollback instead of
87
+ * a gamble on an older binary reading a newer-migrated database.
88
+ */
89
+ export function needsSystemStateSnapshot(posture: DeployPosture): boolean {
90
+ return posture === 'safe';
91
+ }
92
+
93
+ /**
94
+ * Take the system_state snapshot a safe-posture upgrade requires.
95
+ *
96
+ * A host with no default backup storage keeps today's behavior (warn and
97
+ * proceed): refusing every safe upgrade there would break CD for a config
98
+ * gap, and the module-level backup below still runs when the target manifest
99
+ * declares on_backup. A configured-but-failing snapshot refuses the upgrade,
100
+ * same rule `system update` applies to its own snapshot (D7): the DB snapshot
101
+ * is the safety net, and an upgrade that cannot take it should not mutate.
102
+ */
103
+ async function snapshotSystemStateBeforeUpgrade(
104
+ moduleId: string,
105
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
106
+ try {
107
+ if (!getDefaultBackupStorage()) {
108
+ log.warn(
109
+ `${moduleId}: no default backup storage configured — skipping pre-upgrade system_state snapshot`,
110
+ );
111
+ return { ok: true };
112
+ }
113
+ const snapshot = await createSystemStateBackup();
114
+ if (!snapshot.success) {
115
+ return { ok: false, error: snapshot.error ?? 'system_state backup failed' };
116
+ }
117
+ log.success(`System state snapshot taken before upgrading ${moduleId}`);
118
+ return { ok: true };
119
+ } catch (err) {
120
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
121
+ }
122
+ }
123
+
82
124
  export interface PollCandidate {
83
125
  moduleId: string;
84
126
  installed: string;
@@ -168,7 +210,17 @@ async function upgradeOneModule(
168
210
  });
169
211
  log.info(`Upgrading ${moduleId} ${mod.version} → ${targetVersion} (${posture} — ${reason})`);
170
212
 
171
- // Safe → back up first, gated on the TARGET manifest's on_backup hook.
213
+ // Safe → snapshot the celilo DB first (the package rollback pair), then
214
+ // back up the module itself, gated on the TARGET manifest's on_backup hook.
215
+ if (needsSystemStateSnapshot(posture)) {
216
+ const snapshot = await snapshotSystemStateBeforeUpgrade(moduleId);
217
+ if (!snapshot.ok) {
218
+ return {
219
+ success: false,
220
+ error: `Pre-upgrade system_state snapshot failed for ${moduleId}: ${snapshot.error}`,
221
+ };
222
+ }
223
+ }
172
224
  if (needsPreUpgradeBackup(posture, targetManifest)) {
173
225
  const backup = await createModuleBackup(moduleId);
174
226
  if (!backup.success) {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { resetTestDbPath } from '../../test-utils/db-path';
5
6
  import { moduleAudit } from './module-audit';
6
7
  import { moduleVerify } from './module-verify';
7
8
 
@@ -19,7 +20,6 @@ describe('moduleVerify', () => {
19
20
  describe('moduleAudit (deprecation alias)', () => {
20
21
  let dataDir: string;
21
22
  const originalDataDir = process.env.CELILO_DATA_DIR;
22
- const originalDbPath = process.env.CELILO_DB_PATH;
23
23
 
24
24
  beforeEach(() => {
25
25
  dataDir = mkdtempSync(join(tmpdir(), 'celilo-audit-alias-'));
@@ -29,10 +29,9 @@ describe('moduleAudit (deprecation alias)', () => {
29
29
 
30
30
  afterEach(() => {
31
31
  rmSync(dataDir, { recursive: true, force: true });
32
+ resetTestDbPath();
32
33
  if (originalDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
33
34
  else process.env.CELILO_DATA_DIR = originalDataDir;
34
- if (originalDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
35
- else process.env.CELILO_DB_PATH = originalDbPath;
36
35
  });
37
36
 
38
37
  test('writes a deprecation warning to stderr and delegates to verify', async () => {
@@ -102,7 +102,6 @@ export async function moduleVerify(
102
102
  modified: '✗',
103
103
  extra: '!',
104
104
  'stale-baseline': '⚠',
105
- 'stale-generated': '✗',
106
105
  };
107
106
 
108
107
  return {