@celilo/cli 2.2.0 → 2.3.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 (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for `celilo module list`'s jail-exemption marker (per-module-jail-policy
3
+ * task 3.1/3.4).
4
+ *
5
+ * A module with a recorded policy weaker than the system's is marked in the
6
+ * human list and carries a `jailExemptions` roster entry in JSON. The negative
7
+ * half is asserted with the same rigour: no row, no marker, empty roster —
8
+ * a query bug that reported phantom exemptions would look exactly like a
9
+ * correctly empty one otherwise.
10
+ *
11
+ * Isolation mirrors module-jail.test.ts: CELILO_DB_PATH / CELILO_DATA_DIR are
12
+ * set before the SUT is imported.
13
+ */
14
+
15
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { eq } from 'drizzle-orm';
20
+
21
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-module-list-jail-'));
22
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
23
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
24
+
25
+ const { getDb, closeDb } = await import('../../db/client');
26
+ const { moduleJailPolicies, modules, systemConfig } = await import('../../db/schema');
27
+ const { handleModuleList } = await import('./module-list');
28
+ import type { CommandResult, CommandSuccess } from '../types';
29
+
30
+ /** Narrow the result union after asserting the branch, so `.message` typechecks. */
31
+ function expectOk(result: CommandResult): asserts result is CommandSuccess {
32
+ expect(result.success).toBe(true);
33
+ }
34
+
35
+ function insertModule(id: string): void {
36
+ getDb()
37
+ .insert(modules)
38
+ .values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', manifestData: {} })
39
+ .run();
40
+ }
41
+
42
+ function setModulePolicy(moduleId: string, policy: string): void {
43
+ const typed = policy as 'auto' | 'off' | 'required';
44
+ getDb()
45
+ .insert(moduleJailPolicies)
46
+ .values({ moduleId, policy: typed, updatedAt: new Date() })
47
+ .onConflictDoUpdate({
48
+ target: moduleJailPolicies.moduleId,
49
+ set: { policy: typed, updatedAt: new Date() },
50
+ })
51
+ .run();
52
+ }
53
+
54
+ function setSystemPolicy(value: string): void {
55
+ getDb()
56
+ .insert(systemConfig)
57
+ .values({ key: 'hooks.jail_policy', value })
58
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
59
+ .run();
60
+ }
61
+
62
+ function clearModulePolicy(moduleId: string): void {
63
+ getDb().delete(moduleJailPolicies).where(eq(moduleJailPolicies.moduleId, moduleId)).run();
64
+ }
65
+
66
+ afterAll(() => {
67
+ closeDb();
68
+ rmSync(testRoot, { recursive: true, force: true });
69
+ });
70
+
71
+ describe('module list — jail exemption marker (per-module-jail-policy task 3.1)', () => {
72
+ beforeEach(() => {
73
+ const db = getDb();
74
+ db.delete(moduleJailPolicies).run();
75
+ db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
76
+ db.delete(modules).run();
77
+ insertModule('caddy');
78
+ });
79
+
80
+ test('a module weaker than the system is marked, with both policies', async () => {
81
+ setSystemPolicy('auto');
82
+ setModulePolicy('caddy', 'off');
83
+ const result = await handleModuleList({});
84
+ expectOk(result);
85
+ expect(result.message).toContain('caddy');
86
+ expect(result.message).toContain("[jail: off — weaker than the system's auto]");
87
+ });
88
+
89
+ test('a module with no per-module row carries no marker (the negative half of task 3.4)', async () => {
90
+ setSystemPolicy('auto');
91
+ const result = await handleModuleList({});
92
+ expectOk(result);
93
+ expect(result.message).not.toContain('jail:');
94
+ expect(result.message).not.toContain('weaker than');
95
+ });
96
+
97
+ test('a module whose row is not weaker than the system carries no marker', async () => {
98
+ setSystemPolicy('auto');
99
+ setModulePolicy('caddy', 'required');
100
+ const result = await handleModuleList({});
101
+ expectOk(result);
102
+ expect(result.message).not.toContain('jail:');
103
+ });
104
+
105
+ test('a cleared row stops the marker (the exemption is un-set, not hidden)', async () => {
106
+ setSystemPolicy('auto');
107
+ setModulePolicy('caddy', 'off');
108
+ const before = await handleModuleList({});
109
+ expectOk(before);
110
+ expect(before.message).toContain('[jail:');
111
+ clearModulePolicy('caddy');
112
+ const after = await handleModuleList({});
113
+ expectOk(after);
114
+ expect(after.message).not.toContain('[jail:');
115
+ });
116
+
117
+ test('the JSON roster carries the exemptions', async () => {
118
+ setSystemPolicy('auto');
119
+ setModulePolicy('caddy', 'off');
120
+ const result = await handleModuleList({ json: true });
121
+ expectOk(result);
122
+ const parsed = JSON.parse(result.message) as {
123
+ jailExemptions: Array<{ moduleId: string; policy: string; systemPolicy: string }>;
124
+ };
125
+ expect(parsed.jailExemptions).toEqual([
126
+ { moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' },
127
+ ]);
128
+ });
129
+
130
+ test('the JSON roster is empty when nothing is exempted', async () => {
131
+ const result = await handleModuleList({ json: true });
132
+ expectOk(result);
133
+ const parsed = JSON.parse(result.message) as { jailExemptions: unknown[] };
134
+ expect(parsed.jailExemptions).toEqual([]);
135
+ });
136
+ });
@@ -1,6 +1,7 @@
1
1
  import { getDb } from '../../db/client';
2
2
  import { modules } from '../../db/schema';
3
3
  import { loadObservedHealth } from '../../services/alerting/observed-health';
4
+ import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
4
5
  import { formatPausedDuration } from '../../services/module-pause';
5
6
  import { hasFlag } from '../parser';
6
7
  import type { CommandResult } from '../types';
@@ -20,14 +21,29 @@ export async function handleModuleList(
20
21
  // Query all modules
21
22
  const moduleRows = db.select().from(modules).all();
22
23
 
24
+ // Per-module jail exemptions (per-module-jail-policy task 3.1), from the
25
+ // shared collector so list, doctor and audit report the same set. A bad
26
+ // stored row throws here rather than silently listing unmarked — the same
27
+ // row will fail that module's hooks at execution time.
28
+ let jailExemptions: JailExemption[];
29
+ try {
30
+ jailExemptions = collectJailExemptions(db);
31
+ } catch (error) {
32
+ return {
33
+ success: false,
34
+ error: error instanceof Error ? error.message : String(error),
35
+ };
36
+ }
37
+ const exemptedById = new Map(jailExemptions.map((e) => [e.moduleId, e]));
38
+
23
39
  // Stable machine-readable roster — the backbone the MCP composite
24
40
  // troubleshooting tools correlate audit findings against (ce-77i.5).
25
41
  if (hasFlag(flags, 'json')) {
26
42
  return {
27
43
  success: true,
28
- message: JSON.stringify({ modules: moduleRows }, null, 2),
44
+ message: JSON.stringify({ modules: moduleRows, jailExemptions }, null, 2),
29
45
  rawOutput: true,
30
- data: moduleRows,
46
+ data: { modules: moduleRows, jailExemptions },
31
47
  };
32
48
  }
33
49
 
@@ -56,7 +72,9 @@ export async function handleModuleList(
56
72
  module.state === 'PAUSED'
57
73
  ? `PAUSED (${formatPausedDuration(module.pausedAt)})`
58
74
  : module.state;
59
- lines.push(`${module.id} (v${module.version}) - ${stateCell}${healthNote}`);
75
+ lines.push(
76
+ `${module.id} (v${module.version}) - ${stateCell}${healthNote}${jailNote(exemptedById.get(module.id))}`,
77
+ );
60
78
  if (module.state === 'PAUSED' && module.pauseReason) {
61
79
  lines.push(` Paused: ${module.pauseReason}`);
62
80
  }
@@ -75,3 +93,12 @@ export async function handleModuleList(
75
93
  data: moduleRows,
76
94
  };
77
95
  }
96
+
97
+ /**
98
+ * The list marker for an exempted module, or '' when the module has no row
99
+ * (per-module-jail-policy task 3.4: no row, no output).
100
+ */
101
+ function jailNote(exemption: JailExemption | undefined): string {
102
+ if (!exemption) return '';
103
+ return ` [jail: ${exemption.policy} — weaker than the system's ${exemption.systemPolicy}]`;
104
+ }
@@ -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,9 +42,8 @@ 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
- process.env.CELILO_PUBLISH_TOKEN = undefined;
46
+ delete process.env.CELILO_PUBLISH_TOKEN;
47
47
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-no-token-${Date.now()}.db`;
48
48
  process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-${Date.now()}`;
49
49
  try {
@@ -52,10 +52,9 @@ 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
- else process.env.CELILO_DATA_DIR = undefined;
57
+ else delete process.env.CELILO_DATA_DIR;
59
58
  }
60
59
  });
61
60
 
@@ -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 {
@@ -171,15 +169,13 @@ describe('resolveToken — precedence', () => {
171
169
  expect(t).toBe('flag-token');
172
170
  } finally {
173
171
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
174
- 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;
172
+ else delete process.env.CELILO_PUBLISH_TOKEN;
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 {
@@ -187,9 +183,8 @@ describe('resolveToken — precedence', () => {
187
183
  expect(t).toBe('env-token');
188
184
  } finally {
189
185
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
190
- 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;
186
+ else delete process.env.CELILO_PUBLISH_TOKEN;
187
+ resetTestDbPath();
193
188
  }
194
189
  });
195
190
 
@@ -197,9 +192,8 @@ 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
- process.env.CELILO_PUBLISH_TOKEN = undefined;
196
+ delete process.env.CELILO_PUBLISH_TOKEN;
203
197
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`;
204
198
  process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-empty-${Date.now()}`;
205
199
  try {
@@ -207,10 +201,9 @@ 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
- else process.env.CELILO_DATA_DIR = undefined;
206
+ else delete process.env.CELILO_DATA_DIR;
214
207
  }
215
208
  });
216
209
  });
@@ -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', () => {
@@ -106,12 +107,15 @@ description: fixture
106
107
  // Seed the modules table with the "currently installed" record.
107
108
  // We deliberately put a registry-style version string in here so
108
109
  // displayVersion-less upgrades preserve the previousVersion field.
110
+ // celilo#1363: updatedAt is seeded frozen so the success assertions can
111
+ // prove the stamp actually happens whenever the version moves.
109
112
  db.insert(modules)
110
113
  .values({
111
114
  id: 'testmod',
112
115
  name: 'Test Module',
113
116
  sourcePath: installedDir,
114
117
  version: '1.0.0+5',
118
+ updatedAt: new Date('2026-04-25T16:26:31Z'),
115
119
  manifestData: {
116
120
  celilo_contract: '1.0',
117
121
  id: 'testmod',
@@ -124,8 +128,8 @@ description: fixture
124
128
 
125
129
  afterEach(() => {
126
130
  rmSync(tempDir, { recursive: true, force: true });
127
- process.env.CELILO_DB_PATH = undefined;
128
- process.env.CELILO_ORIGINAL_CWD = undefined;
131
+ resetTestDbPath();
132
+ delete process.env.CELILO_ORIGINAL_CWD;
129
133
  });
130
134
 
131
135
  test('returns previousVersion + newVersion in the success outcome', async () => {
@@ -152,6 +156,18 @@ description: fixture
152
156
  expect(row.version).toBe('1.0.0+6');
153
157
  });
154
158
 
159
+ // celilo#1363: the version moved (1.0.5+5 → +6) while updatedAt stayed at
160
+ // 2026-04-25, so the audit read the module as untouched and fine. The stamp
161
+ // must happen in updateOne itself, where every version write lives.
162
+ test('writing a new version stamps updatedAt', async () => {
163
+ const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
164
+ expect(result.status).toBe('success');
165
+
166
+ const row = db.select().from(modules).all()[0];
167
+ expect(row.version).toBe('1.0.0+6');
168
+ expect(row.updatedAt?.getTime()).toBeGreaterThan(new Date('2026-04-25T16:26:31Z').getTime());
169
+ });
170
+
155
171
  test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
156
172
  // We can't easily intercept the log helpers' output without adding test
157
173
  // hooks, so instead we exercise that the call simply succeeds and
@@ -256,7 +272,7 @@ subscriptions:
256
272
  bus.close();
257
273
  }
258
274
  } finally {
259
- process.env.EVENT_BUS_DB = undefined;
275
+ delete process.env.EVENT_BUS_DB;
260
276
  }
261
277
  });
262
278
  });
@@ -313,9 +329,9 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
313
329
  afterEach(() => {
314
330
  server.stop(true);
315
331
  rmSync(tempDir, { recursive: true, force: true });
316
- process.env.CELILO_DB_PATH = undefined;
317
- process.env.CELILO_ORIGINAL_CWD = undefined;
318
- process.env.EVENT_BUS_DB = undefined;
332
+ resetTestDbPath();
333
+ delete process.env.CELILO_ORIGINAL_CWD;
334
+ delete process.env.EVENT_BUS_DB;
319
335
  });
320
336
 
321
337
  test('reports it as unanswered, never as declined, and fails the sweep', async () => {
@@ -387,8 +403,8 @@ description: fixture
387
403
 
388
404
  afterEach(() => {
389
405
  rmSync(tempDir, { recursive: true, force: true });
390
- process.env.CELILO_DB_PATH = undefined;
391
- process.env.CELILO_ORIGINAL_CWD = undefined;
406
+ resetTestDbPath();
407
+ delete process.env.CELILO_ORIGINAL_CWD;
392
408
  });
393
409
 
394
410
  test('refuses by name, and writes NOTHING', async () => {
@@ -499,8 +515,8 @@ describe('updateOne — an update that fails partway leaves the install untouche
499
515
 
500
516
  afterEach(() => {
501
517
  rmSync(tempDir, { recursive: true, force: true });
502
- process.env.CELILO_DB_PATH = undefined;
503
- process.env.CELILO_ORIGINAL_CWD = undefined;
518
+ resetTestDbPath();
519
+ delete process.env.CELILO_ORIGINAL_CWD;
504
520
  });
505
521
 
506
522
  test('a failed update leaves the installed tree byte-identical', async () => {
@@ -594,8 +610,8 @@ describe('updateOne — a .netapp package source', () => {
594
610
 
595
611
  afterEach(() => {
596
612
  rmSync(tempDir, { recursive: true, force: true });
597
- process.env.CELILO_DB_PATH = undefined;
598
- process.env.CELILO_ORIGINAL_CWD = undefined;
613
+ resetTestDbPath();
614
+ delete process.env.CELILO_ORIGINAL_CWD;
599
615
  });
600
616
 
601
617
  // ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and
@@ -475,12 +475,15 @@ export async function updateOne(
475
475
 
476
476
  // Update manifest in database. We persist the display version (with
477
477
  // +N when known) so subsequent `module list` / `module update` calls
478
- // see the same version string the registry reported.
478
+ // see the same version string the registry reported. updatedAt moves with
479
+ // the version: a frozen timestamp beside a new version read as "nothing
480
+ // happened here" to the audit (celilo#1363).
479
481
  db.update(modules)
480
482
  .set({
481
483
  manifestData: newManifest as unknown as Record<string, unknown>,
482
484
  version: newVersion,
483
485
  name: newManifest.name,
486
+ updatedAt: new Date(),
484
487
  })
485
488
  .where(eq(modules.id, moduleId))
486
489
  .run();
@@ -1,12 +1,25 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import type { Mock } from 'bun:test';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { eq } from 'drizzle-orm';
7
+ import { create as tarCreate } from 'tar';
8
+ import { type DbClient, getDb } from '../../db/client';
9
+ import { type ModuleState, modules } from '../../db/schema';
2
10
  import type { ModuleManifest } from '../../manifest/schema';
11
+ import { RegistryClient } from '../../registry/client';
12
+ import * as moduleDeploy from '../../services/module-deploy';
13
+ import { resetTestDbPath } from '../../test-utils/db-path';
3
14
  import {
4
15
  type PollCandidate,
5
16
  isPollInvocation,
6
17
  needsPreUpgradeBackup,
18
+ needsSystemStateSnapshot,
7
19
  pickAutoUpgrade,
8
20
  pickUpgradePolicy,
9
21
  selectPollTargets,
22
+ upgradeOneModule,
10
23
  } from './module-upgrade';
11
24
 
12
25
  /** Minimal manifest fixture; only `hooks` matters for the backup gate. */
@@ -69,6 +82,7 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
69
82
  installed: '1.0.0',
70
83
  latest: '1.0.1',
71
84
  autoUpgrade: true,
85
+ inError: false,
72
86
  ...over,
73
87
  });
74
88
 
@@ -91,6 +105,13 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
91
105
  expect(selectPollTargets([base({ latest: null })])).toEqual([]);
92
106
  });
93
107
 
108
+ // celilo#1363: the poll burned five attempts redeploying celilo-website
109
+ // against a failure that never went away. A module in ERROR stays skipped
110
+ // until an attended health check clears it.
111
+ test('skips modules in ERROR state (a failed upgrade deploy)', () => {
112
+ expect(selectPollTargets([base({ inError: true })])).toEqual([]);
113
+ });
114
+
94
115
  test('picks only the eligible subset from a mixed set', () => {
95
116
  const targets = selectPollTargets([
96
117
  base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }),
@@ -102,6 +123,20 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
102
123
  });
103
124
  });
104
125
 
126
+ // A 0.x minor/major bump can migrate the celilo DB schema; the system_state
127
+ // snapshot is what makes a later `apt install celilo=<old>` (or dpkg -i from
128
+ // the apt cache) a real rollback instead of a gamble. Patch/revision bumps
129
+ // don't migrate, so they stay fast.
130
+ describe('needsSystemStateSnapshot (safe posture snapshots the celilo DB before mutating)', () => {
131
+ test('safe posture (minor/major) requires the snapshot', () => {
132
+ expect(needsSystemStateSnapshot('safe')).toBe(true);
133
+ });
134
+
135
+ test('fast posture (patch/revision) skips the snapshot', () => {
136
+ expect(needsSystemStateSnapshot('fast')).toBe(false);
137
+ });
138
+ });
139
+
105
140
  describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifest)', () => {
106
141
  // The regression: the upgrade that FIRST adds on_backup must still back up.
107
142
  // The installed version lacked the hook; the target (passed here) adds it.
@@ -121,3 +156,97 @@ describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifes
121
156
  expect(needsPreUpgradeBackup('safe', manifest(undefined))).toBe(false);
122
157
  });
123
158
  });
159
+
160
+ // celilo#1363: fetchAndUpdate commits the new version to the store BEFORE the
161
+ // deploy runs, so a failed deploy left a new version recorded beside a stale
162
+ // VERIFIED state and a frozen updatedAt, and the audit read the module as
163
+ // fine. Option A: the row must say what happened — state ERROR with the
164
+ // error, and updatedAt moving whenever the version moves.
165
+ describe('upgradeOneModule — a failed deploy records the failure in the row (celilo#1363)', () => {
166
+ const FROZEN = new Date('2026-04-25T16:26:31Z');
167
+ let tempDir: string;
168
+ let installedDir: string;
169
+ let db: DbClient;
170
+ let getIndexSpy: Mock<(name: string) => Promise<unknown[]>>;
171
+ let downloadSpy: Mock<(name: string, vers: string, cksum?: string) => Promise<ArrayBuffer>>;
172
+ let deploySpy: Mock<typeof moduleDeploy.deployModule>;
173
+
174
+ beforeEach(async () => {
175
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
176
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
177
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
178
+
179
+ installedDir = join(tempDir, 'installed', 'testmod');
180
+ mkdirSync(installedDir, { recursive: true });
181
+ writeFileSync(
182
+ join(installedDir, 'manifest.yml'),
183
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
184
+ );
185
+
186
+ db = getDb();
187
+ db.insert(modules)
188
+ .values({
189
+ id: 'testmod',
190
+ name: 'Test Module',
191
+ sourcePath: installedDir,
192
+ version: '1.0.0+5',
193
+ state: 'VERIFIED',
194
+ updatedAt: FROZEN,
195
+ manifestData: {
196
+ celilo_contract: '1.0',
197
+ id: 'testmod',
198
+ name: 'Test Module',
199
+ version: '1.0.0',
200
+ },
201
+ })
202
+ .run();
203
+
204
+ // Build the registry package for a patch bump (1.0.0+5 → 1.0.0+6).
205
+ // Patch/revision → fast posture, so no snapshot and no backup are needed
206
+ // on the path to the deploy.
207
+ const srcDir = join(tempDir, 'pkg-src');
208
+ mkdirSync(srcDir, { recursive: true });
209
+ writeFileSync(
210
+ join(srcDir, 'manifest.yml'),
211
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
212
+ );
213
+ const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
214
+ await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
215
+ const pkgBytes = new Uint8Array(await Bun.file(pkgPath).arrayBuffer());
216
+
217
+ getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([
218
+ { name: 'testmod', vers: '1.0.0+6', deps: [], cksum: 'abc', yanked: false },
219
+ ]);
220
+ downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(pkgBytes.buffer);
221
+ deploySpy = spyOn(moduleDeploy, 'deployModule').mockResolvedValue({
222
+ success: false,
223
+ error: 'ssh: connection refused',
224
+ phases: {},
225
+ });
226
+ });
227
+
228
+ afterEach(() => {
229
+ getIndexSpy.mockRestore();
230
+ downloadSpy.mockRestore();
231
+ deploySpy.mockRestore();
232
+ rmSync(tempDir, { recursive: true, force: true });
233
+ resetTestDbPath();
234
+ delete process.env.CELILO_ORIGINAL_CWD;
235
+ });
236
+
237
+ test('a deploy failure leaves state ERROR, the new version, and a moved updatedAt', async () => {
238
+ const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
239
+ if (!mod) throw new Error('seed row missing');
240
+
241
+ const result = await upgradeOneModule(mod, '1.0.0+6', new RegistryClient(''), db, {});
242
+
243
+ expect(result.success).toBe(false);
244
+ const row = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
245
+ if (!row) throw new Error('row missing after upgrade');
246
+ expect(row.version).toBe('1.0.0+6');
247
+ expect(row.state).toBe<ModuleState>('ERROR');
248
+ expect(row.errorMessage).toContain('1.0.0+6');
249
+ expect(row.errorMessage).toContain('ssh: connection refused');
250
+ expect(row.updatedAt?.getTime()).toBeGreaterThan(FROZEN.getTime());
251
+ });
252
+ });