@celilo/cli 2.2.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 (115) hide show
  1. package/package.json +2 -2
  2. package/schemas/system_config.json +5 -0
  3. package/src/ansible/inventory.test.ts +2 -1
  4. package/src/api/sessions.test.ts +2 -1
  5. package/src/cli/backup-rename.test.ts +2 -1
  6. package/src/cli/cli.test.ts +2 -1
  7. package/src/cli/commands/console-get-chain.test.ts +2 -1
  8. package/src/cli/commands/firewall-interface-list.test.ts +2 -1
  9. package/src/cli/commands/module-config.test.ts +2 -1
  10. package/src/cli/commands/module-deploy.ts +8 -2
  11. package/src/cli/commands/module-generate.test.ts +53 -0
  12. package/src/cli/commands/module-generate.ts +31 -26
  13. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  14. package/src/cli/commands/module-import-registry.test.ts +2 -1
  15. package/src/cli/commands/module-operations.test.ts +2 -1
  16. package/src/cli/commands/module-publish.test.ts +5 -12
  17. package/src/cli/commands/module-update.test.ts +6 -5
  18. package/src/cli/commands/module-upgrade.test.ts +15 -0
  19. package/src/cli/commands/module-upgrade.ts +54 -2
  20. package/src/cli/commands/module-verify.test.ts +2 -3
  21. package/src/cli/commands/module-verify.ts +0 -1
  22. package/src/cli/commands/notify-config.test.ts +5 -3
  23. package/src/cli/commands/registry-owner.test.ts +2 -1
  24. package/src/cli/commands/registry-token.test.ts +2 -1
  25. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  26. package/src/cli/commands/system-config.test.ts +148 -0
  27. package/src/cli/commands/system-config.ts +26 -1
  28. package/src/cli/commands/system-doctor.test.ts +71 -0
  29. package/src/cli/commands/system-doctor.ts +110 -24
  30. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  31. package/src/cli/commands/system-migrate.test.ts +2 -1
  32. package/src/cli/index.ts +2 -1
  33. package/src/cli/restore-command.test.ts +2 -1
  34. package/src/cli/restore-migration-failure.test.ts +2 -1
  35. package/src/config/paths.test.ts +3 -3
  36. package/src/db/migrate.test.ts +2 -1
  37. package/src/db/migrate.ts +16 -16
  38. package/src/hooks/capability-loader.test.ts +78 -0
  39. package/src/hooks/capability-loader.ts +37 -2
  40. package/src/hooks/executor.test.ts +87 -1
  41. package/src/hooks/executor.ts +57 -21
  42. package/src/hooks/hook-store.test.ts +6 -0
  43. package/src/hooks/hook-trespass.test.ts +17 -7
  44. package/src/hooks/jail.test.ts +61 -2
  45. package/src/hooks/jail.ts +53 -1
  46. package/src/module/packaging/audit.ts +9 -26
  47. package/src/module/packaging/build-paths.test.ts +127 -0
  48. package/src/module/packaging/build-paths.ts +175 -0
  49. package/src/module/packaging/build.test.ts +71 -1
  50. package/src/module/packaging/build.ts +60 -0
  51. package/src/module/packaging/extract.ts +1 -5
  52. package/src/policy/module-script-scan.ts +0 -40
  53. package/src/policy/no-swallowed-refusal.test.ts +9 -10
  54. package/src/services/alerting/ack.test.ts +2 -1
  55. package/src/services/alerting/cadence-migration.test.ts +3 -2
  56. package/src/services/alerting/coverage-source.test.ts +2 -1
  57. package/src/services/alerting/deferral.test.ts +2 -1
  58. package/src/services/alerting/delivery-loop.test.ts +2 -1
  59. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  60. package/src/services/alerting/inbound-poller.test.ts +2 -1
  61. package/src/services/alerting/inbound.test.ts +2 -1
  62. package/src/services/alerting/notification-responder.test.ts +2 -1
  63. package/src/services/alerting/run-monitor.test.ts +2 -1
  64. package/src/services/alerting/store.test.ts +2 -1
  65. package/src/services/alerting/sweep-runner.test.ts +2 -1
  66. package/src/services/alerting/tokens.test.ts +2 -1
  67. package/src/services/aspect-approvals.test.ts +2 -1
  68. package/src/services/aspect-reconcile.test.ts +4 -3
  69. package/src/services/aspect-runner.test.ts +2 -1
  70. package/src/services/audit/module-integrity.test.ts +0 -21
  71. package/src/services/audit/module-integrity.ts +0 -14
  72. package/src/services/backup-age-agreement.test.ts +2 -1
  73. package/src/services/backup-envelope-roundtrip.test.ts +2 -1
  74. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  75. package/src/services/bus-ensure-flow.test.ts +2 -1
  76. package/src/services/bus-interview-park.test.ts +2 -1
  77. package/src/services/bus-secret-flow.test.ts +2 -1
  78. package/src/services/capability-table-rows.test.ts +2 -1
  79. package/src/services/consumer-cleanup.test.ts +3 -2
  80. package/src/services/container-service.test.ts +2 -1
  81. package/src/services/control-plane-bootstrap.test.ts +2 -1
  82. package/src/services/cross-module-read.test.ts +2 -1
  83. package/src/services/deploy-validation.test.ts +3 -2
  84. package/src/services/dns-internal-records.test.ts +3 -2
  85. package/src/services/dns-provider-backfill.test.ts +2 -1
  86. package/src/services/dns-registrations.test.ts +2 -1
  87. package/src/services/ensure-interview.test.ts +3 -2
  88. package/src/services/fleet-checks.test.ts +3 -2
  89. package/src/services/fleet-key.test.ts +3 -2
  90. package/src/services/infrastructure-selector.test.ts +2 -1
  91. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  92. package/src/services/machine-pool.test.ts +2 -1
  93. package/src/services/module-config.test.ts +2 -1
  94. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  95. package/src/services/module-deploy.ts +36 -20
  96. package/src/services/module-operations.test.ts +2 -1
  97. package/src/services/module-subscriptions.test.ts +2 -1
  98. package/src/services/port-forwards.test.ts +2 -1
  99. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  100. package/src/services/proxmox-reconcile.test.ts +2 -1
  101. package/src/services/restore-from-file.test.ts +3 -2
  102. package/src/services/restore-preflight.test.ts +2 -1
  103. package/src/services/secret-schema-loader.test.ts +2 -1
  104. package/src/services/ssh-key-manager.test.ts +2 -1
  105. package/src/services/static-content-converge.test.ts +5 -4
  106. package/src/services/static-content-converge.ts +33 -10
  107. package/src/services/system-state-stage.test.ts +2 -1
  108. package/src/services/trusted-sources.test.ts +3 -2
  109. package/src/services/zone-detector.test.ts +2 -1
  110. package/src/test-utils/bus-responder.ts +5 -3
  111. package/src/test-utils/db-path.ts +25 -0
  112. package/src/test-utils/integration.ts +7 -0
  113. package/src/test-utils/module-fixtures.ts +5 -6
  114. package/src/module/packaging/generated-plane.test.ts +0 -79
  115. package/src/module/packaging/generated-plane.ts +0 -134
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,7 @@
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
61
  "@celilo/capabilities": "^4.2.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.11.0",
63
+ "@celilo/core": "^0.11.1",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -118,6 +118,11 @@
118
118
  "type": "string",
119
119
  "description": "SSH public key for container access"
120
120
  },
121
+ "hooks.jail_policy": {
122
+ "type": "string",
123
+ "pattern": "^(auto|off|required)$",
124
+ "description": "Hook jail policy: 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off' (hooks run unjailed). Precedence: the CELILO_HOOK_JAIL environment variable wins over this stored value. Run 'celilo system doctor' to see the effective policy and which source it came from."
125
+ },
121
126
  "public_dns.resolver": {
122
127
  "type": "string",
123
128
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
@@ -4,6 +4,7 @@ import { rm } from 'node:fs/promises';
4
4
  import { type DbClient, createDbClient } from '../db/client';
5
5
  import { systemConfig } from '../db/schema';
6
6
  import { upsertModuleConfig } from '../services/module-config';
7
+ import { resetTestDbPath } from '../test-utils/db-path';
7
8
  import {
8
9
  buildHostVars,
9
10
  buildSystemVars,
@@ -286,7 +287,7 @@ describe('Database integration', () => {
286
287
  afterEach(async () => {
287
288
  db.$client.close();
288
289
  // Clean up environment variable
289
- process.env.CELILO_DB_PATH = undefined;
290
+ resetTestDbPath();
290
291
  if (existsSync(TEST_DB_PATH)) {
291
292
  await rm(TEST_DB_PATH);
292
293
  }
@@ -32,6 +32,7 @@ import { handleModuleUpdate } from '../cli/commands/module-update';
32
32
  import { getDb } from '../db/client';
33
33
  import { modules } from '../db/schema';
34
34
  import { RESPONDER_PROBE_EVENT } from '../services/responder-probe';
35
+ import { resetTestDbPath } from '../test-utils/db-path';
35
36
  import { SessionWriter, listSessions, reapExpiredSessions } from './sessions';
36
37
 
37
38
  const NO_SCHEMAS = defineEvents({});
@@ -99,7 +100,7 @@ beforeEach(() => {
99
100
  afterEach(() => {
100
101
  server.stop(true);
101
102
  rmSync(dir, { recursive: true, force: true });
102
- process.env.CELILO_DB_PATH = undefined;
103
+ resetTestDbPath();
103
104
  process.env.CELILO_DATA_DIR = undefined;
104
105
  process.env.CELILO_ORIGINAL_CWD = undefined;
105
106
  process.env.EVENT_BUS_DB = undefined;
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import { closeDb } from '../db/client';
18
18
  import { runMigrations } from '../db/migrate';
19
+ import { resetTestDbPath } from '../test-utils/db-path';
19
20
  import { runCli } from './index';
20
21
 
21
22
  describe('celilo module backup (renamed surface)', () => {
@@ -48,7 +49,7 @@ describe('celilo module backup (renamed surface)', () => {
48
49
  warnSpy = null;
49
50
  }
50
51
  closeDb();
51
- process.env.CELILO_DB_PATH = undefined;
52
+ resetTestDbPath();
52
53
  process.env.CELILO_MASTER_KEY_PATH = undefined;
53
54
  process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
54
55
  try {
@@ -8,6 +8,7 @@ import { moduleConfigs, modules, secrets } from '../db/schema';
8
8
  import { encryptSecret } from '../secrets/encryption';
9
9
  import { generateMasterKey, writeMasterKey } from '../secrets/master-key';
10
10
  import { upsertModuleConfig } from '../services/module-config';
11
+ import { resetTestDbPath } from '../test-utils/db-path';
11
12
  import { runCli } from './index';
12
13
 
13
14
  const TEST_DB_PATH = `./test-cli-${Date.now()}-${Math.random()}.db`;
@@ -147,7 +148,7 @@ description: Test module for CLI
147
148
 
148
149
  // Clear environment variables
149
150
  process.env.CELILO_MASTER_KEY_PATH = undefined;
150
- process.env.CELILO_DB_PATH = undefined;
151
+ resetTestDbPath();
151
152
  });
152
153
 
153
154
  describe('help', () => {
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
15
  import { mkdtempSync, rmSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
+ import { resetTestDbPath } from '../../test-utils/db-path';
18
19
 
19
20
  let testDir: string;
20
21
 
@@ -26,7 +27,7 @@ beforeEach(() => {
26
27
 
27
28
  afterEach(() => {
28
29
  rmSync(testDir, { recursive: true, force: true });
29
- process.env.CELILO_DB_PATH = undefined;
30
+ resetTestDbPath();
30
31
  });
31
32
 
32
33
  /** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
12
12
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join } from 'node:path';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
 
16
17
  // The command reaches boxes ONLY through these two modules, and both are
17
18
  // mocked below so the tests can drive the live-read and fallback paths without
@@ -67,7 +68,7 @@ beforeEach(() => {
67
68
  afterEach(() => {
68
69
  console.log = originalLog;
69
70
  rmSync(testDir, { recursive: true, force: true });
70
- process.env.CELILO_DB_PATH = undefined;
71
+ resetTestDbPath();
71
72
  process.env.CELILO_DATA_DIR = undefined;
72
73
  process.env.CELILO_MASTER_KEY_PATH = undefined;
73
74
  });
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
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,
@@ -51,7 +52,7 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
51
52
 
52
53
  afterEach(() => {
53
54
  rmSync(tempDir, { recursive: true, force: true });
54
- process.env.CELILO_DB_PATH = undefined;
55
+ resetTestDbPath();
55
56
  });
56
57
 
57
58
  test('rejects an infrastructure-managed key (no silent accept-then-override)', async () => {
@@ -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
 
@@ -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
  }
@@ -21,6 +21,7 @@ import { eq } from 'drizzle-orm';
21
21
  import { create as tarCreate } from 'tar';
22
22
  import { type DbClient, getDb } from '../../db/client';
23
23
  import { moduleIntegrity, modules } from '../../db/schema';
24
+ import { resetTestDbPath } from '../../test-utils/db-path';
24
25
  import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
25
26
 
26
27
  describe('classifyVersionChange', () => {
@@ -124,7 +125,7 @@ description: fixture
124
125
 
125
126
  afterEach(() => {
126
127
  rmSync(tempDir, { recursive: true, force: true });
127
- process.env.CELILO_DB_PATH = undefined;
128
+ resetTestDbPath();
128
129
  process.env.CELILO_ORIGINAL_CWD = undefined;
129
130
  });
130
131
 
@@ -313,7 +314,7 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
313
314
  afterEach(() => {
314
315
  server.stop(true);
315
316
  rmSync(tempDir, { recursive: true, force: true });
316
- process.env.CELILO_DB_PATH = undefined;
317
+ resetTestDbPath();
317
318
  process.env.CELILO_ORIGINAL_CWD = undefined;
318
319
  process.env.EVENT_BUS_DB = undefined;
319
320
  });
@@ -387,7 +388,7 @@ description: fixture
387
388
 
388
389
  afterEach(() => {
389
390
  rmSync(tempDir, { recursive: true, force: true });
390
- process.env.CELILO_DB_PATH = undefined;
391
+ resetTestDbPath();
391
392
  process.env.CELILO_ORIGINAL_CWD = undefined;
392
393
  });
393
394
 
@@ -499,7 +500,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
499
500
 
500
501
  afterEach(() => {
501
502
  rmSync(tempDir, { recursive: true, force: true });
502
- process.env.CELILO_DB_PATH = undefined;
503
+ resetTestDbPath();
503
504
  process.env.CELILO_ORIGINAL_CWD = undefined;
504
505
  });
505
506
 
@@ -594,7 +595,7 @@ describe('updateOne — a .netapp package source', () => {
594
595
 
595
596
  afterEach(() => {
596
597
  rmSync(tempDir, { recursive: true, force: true });
597
- process.env.CELILO_DB_PATH = undefined;
598
+ resetTestDbPath();
598
599
  process.env.CELILO_ORIGINAL_CWD = undefined;
599
600
  });
600
601
 
@@ -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 {