@celilo/cli 2.2.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -43,7 +43,7 @@ describe('handleModulePublish — validation', () => {
|
|
|
43
43
|
// will skip the validation path it's trying to exercise.
|
|
44
44
|
const origToken = process.env.CELILO_PUBLISH_TOKEN;
|
|
45
45
|
const origDataDir = process.env.CELILO_DATA_DIR;
|
|
46
|
-
process.env.CELILO_PUBLISH_TOKEN
|
|
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 {
|
|
@@ -54,7 +54,7 @@ describe('handleModulePublish — validation', () => {
|
|
|
54
54
|
if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken;
|
|
55
55
|
resetTestDbPath();
|
|
56
56
|
if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
|
|
57
|
-
else process.env.CELILO_DATA_DIR
|
|
57
|
+
else delete process.env.CELILO_DATA_DIR;
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
|
|
@@ -99,6 +99,28 @@ describe('handleModulePublish — manifest reading', () => {
|
|
|
99
99
|
expect(result.success).toBe(false);
|
|
100
100
|
if (!result.success) expect(result.error).toContain('manifest.yml missing id or version');
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test('a failed module does not stop the sweep — every module is attempted and every failure is named', async () => {
|
|
104
|
+
// celilo#1369: the loop bailed at the first failed module, so modules
|
|
105
|
+
// after it were never attempted (release run 6705 left nine modules
|
|
106
|
+
// unpublished). Three dirs that each fail before any registry access,
|
|
107
|
+
// at three different checks, prove the loop reaches all of them: the
|
|
108
|
+
// returned error must name every dir, not only the first.
|
|
109
|
+
const dirA = join(TEST_DIR, 'a-missing-id');
|
|
110
|
+
const dirB = join(TEST_DIR, 'b-missing-manifest');
|
|
111
|
+
const dirC = join(TEST_DIR, 'c-missing-version');
|
|
112
|
+
for (const d of [dirA, dirB, dirC]) await mkdir(d, { recursive: true });
|
|
113
|
+
await writeFile(join(dirA, 'manifest.yml'), 'version: "1.0.0"\n');
|
|
114
|
+
await writeFile(join(dirC, 'manifest.yml'), 'id: c-module\n');
|
|
115
|
+
|
|
116
|
+
const result = await handleModulePublish([dirA, dirB, dirC], { token: 'tok' });
|
|
117
|
+
expect(result.success).toBe(false);
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
expect(result.error).toContain(dirA);
|
|
120
|
+
expect(result.error).toContain(dirB);
|
|
121
|
+
expect(result.error).toContain(dirC);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
102
124
|
});
|
|
103
125
|
|
|
104
126
|
// ── Revision auto-detection algorithm ────────────────────────────────────────
|
|
@@ -169,7 +191,7 @@ describe('resolveToken — precedence', () => {
|
|
|
169
191
|
expect(t).toBe('flag-token');
|
|
170
192
|
} finally {
|
|
171
193
|
if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
|
|
172
|
-
else process.env.CELILO_PUBLISH_TOKEN
|
|
194
|
+
else delete process.env.CELILO_PUBLISH_TOKEN;
|
|
173
195
|
resetTestDbPath();
|
|
174
196
|
}
|
|
175
197
|
});
|
|
@@ -183,7 +205,7 @@ describe('resolveToken — precedence', () => {
|
|
|
183
205
|
expect(t).toBe('env-token');
|
|
184
206
|
} finally {
|
|
185
207
|
if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
|
|
186
|
-
else process.env.CELILO_PUBLISH_TOKEN
|
|
208
|
+
else delete process.env.CELILO_PUBLISH_TOKEN;
|
|
187
209
|
resetTestDbPath();
|
|
188
210
|
}
|
|
189
211
|
});
|
|
@@ -193,7 +215,7 @@ describe('resolveToken — precedence', () => {
|
|
|
193
215
|
// DB and returns null gracefully.
|
|
194
216
|
const origEnv = process.env.CELILO_PUBLISH_TOKEN;
|
|
195
217
|
const origDataDir = process.env.CELILO_DATA_DIR;
|
|
196
|
-
process.env.CELILO_PUBLISH_TOKEN
|
|
218
|
+
delete process.env.CELILO_PUBLISH_TOKEN;
|
|
197
219
|
process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`;
|
|
198
220
|
process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-empty-${Date.now()}`;
|
|
199
221
|
try {
|
|
@@ -203,7 +225,7 @@ describe('resolveToken — precedence', () => {
|
|
|
203
225
|
if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
|
|
204
226
|
resetTestDbPath();
|
|
205
227
|
if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
|
|
206
|
-
else process.env.CELILO_DATA_DIR
|
|
228
|
+
else delete process.env.CELILO_DATA_DIR;
|
|
207
229
|
}
|
|
208
230
|
});
|
|
209
231
|
});
|
|
@@ -385,23 +385,25 @@ export async function handleModulePublish(
|
|
|
385
385
|
for (const moduleDir of args) {
|
|
386
386
|
const outcome = await publishOneModule(moduleDir, opts);
|
|
387
387
|
outcomes.push(outcome);
|
|
388
|
-
if (outcome.status === 'failed') {
|
|
389
|
-
// Print summary so far, then bail. Publishes are non-destructive so
|
|
390
|
-
// re-running after fixing the underlying issue picks up where we left
|
|
391
|
-
// off (already-published modules are skipped at the registry level
|
|
392
|
-
// when --revision is explicit, and auto-revision just picks the next
|
|
393
|
-
// slot — nothing duplicates).
|
|
394
|
-
printSummary(outcomes);
|
|
395
|
-
return {
|
|
396
|
-
success: false,
|
|
397
|
-
error: outcome.message,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
388
|
console.log(outcome.message);
|
|
401
389
|
}
|
|
402
390
|
|
|
391
|
+
// celilo#1369: every module is attempted even after a failure, and every
|
|
392
|
+
// failure is reported together at the end. A publish is non-destructive so
|
|
393
|
+
// re-running after fixing the underlying issue picks up where we left off
|
|
394
|
+
// (already-published modules are skipped at the registry level when
|
|
395
|
+
// --revision is explicit, and auto-revision just picks the next slot —
|
|
396
|
+
// nothing duplicates).
|
|
403
397
|
printSummary(outcomes);
|
|
404
398
|
|
|
399
|
+
const failed = outcomes.filter((o) => o.status === 'failed');
|
|
400
|
+
if (failed.length > 0) {
|
|
401
|
+
return {
|
|
402
|
+
success: false,
|
|
403
|
+
error: failed.map((o) => o.message).join('\n'),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
405
407
|
if (args.length === 1) {
|
|
406
408
|
// Backward-compat single-module shape — keep the existing message/data
|
|
407
409
|
// contract for callers (and tests) that depend on it. By this point no
|
|
@@ -107,12 +107,15 @@ description: fixture
|
|
|
107
107
|
// Seed the modules table with the "currently installed" record.
|
|
108
108
|
// We deliberately put a registry-style version string in here so
|
|
109
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.
|
|
110
112
|
db.insert(modules)
|
|
111
113
|
.values({
|
|
112
114
|
id: 'testmod',
|
|
113
115
|
name: 'Test Module',
|
|
114
116
|
sourcePath: installedDir,
|
|
115
117
|
version: '1.0.0+5',
|
|
118
|
+
updatedAt: new Date('2026-04-25T16:26:31Z'),
|
|
116
119
|
manifestData: {
|
|
117
120
|
celilo_contract: '1.0',
|
|
118
121
|
id: 'testmod',
|
|
@@ -126,7 +129,7 @@ description: fixture
|
|
|
126
129
|
afterEach(() => {
|
|
127
130
|
rmSync(tempDir, { recursive: true, force: true });
|
|
128
131
|
resetTestDbPath();
|
|
129
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
132
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
130
133
|
});
|
|
131
134
|
|
|
132
135
|
test('returns previousVersion + newVersion in the success outcome', async () => {
|
|
@@ -153,6 +156,18 @@ description: fixture
|
|
|
153
156
|
expect(row.version).toBe('1.0.0+6');
|
|
154
157
|
});
|
|
155
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
|
+
|
|
156
171
|
test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
|
|
157
172
|
// We can't easily intercept the log helpers' output without adding test
|
|
158
173
|
// hooks, so instead we exercise that the call simply succeeds and
|
|
@@ -257,7 +272,7 @@ subscriptions:
|
|
|
257
272
|
bus.close();
|
|
258
273
|
}
|
|
259
274
|
} finally {
|
|
260
|
-
process.env.EVENT_BUS_DB
|
|
275
|
+
delete process.env.EVENT_BUS_DB;
|
|
261
276
|
}
|
|
262
277
|
});
|
|
263
278
|
});
|
|
@@ -315,8 +330,8 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
|
|
|
315
330
|
server.stop(true);
|
|
316
331
|
rmSync(tempDir, { recursive: true, force: true });
|
|
317
332
|
resetTestDbPath();
|
|
318
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
319
|
-
process.env.EVENT_BUS_DB
|
|
333
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
334
|
+
delete process.env.EVENT_BUS_DB;
|
|
320
335
|
});
|
|
321
336
|
|
|
322
337
|
test('reports it as unanswered, never as declined, and fails the sweep', async () => {
|
|
@@ -389,7 +404,7 @@ description: fixture
|
|
|
389
404
|
afterEach(() => {
|
|
390
405
|
rmSync(tempDir, { recursive: true, force: true });
|
|
391
406
|
resetTestDbPath();
|
|
392
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
407
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
393
408
|
});
|
|
394
409
|
|
|
395
410
|
test('refuses by name, and writes NOTHING', async () => {
|
|
@@ -501,7 +516,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
501
516
|
afterEach(() => {
|
|
502
517
|
rmSync(tempDir, { recursive: true, force: true });
|
|
503
518
|
resetTestDbPath();
|
|
504
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
519
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
505
520
|
});
|
|
506
521
|
|
|
507
522
|
test('a failed update leaves the installed tree byte-identical', async () => {
|
|
@@ -596,7 +611,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
596
611
|
afterEach(() => {
|
|
597
612
|
rmSync(tempDir, { recursive: true, force: true });
|
|
598
613
|
resetTestDbPath();
|
|
599
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
614
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
600
615
|
});
|
|
601
616
|
|
|
602
617
|
// ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and
|
|
@@ -52,7 +52,7 @@ type UpdateOutcome =
|
|
|
52
52
|
| { status: 'failed'; moduleId: string; error: string }
|
|
53
53
|
// `skipped` means the path expanded from a glob but isn't an
|
|
54
54
|
// upgradable target — either no manifest at all (probably a non-
|
|
55
|
-
// module sibling like `modules/
|
|
55
|
+
// module sibling like `modules/__archive__/`) or a real module that
|
|
56
56
|
// isn't installed in this celilo. Treated as a soft pass so
|
|
57
57
|
// `celilo module update modules/*` does what users expect.
|
|
58
58
|
| { status: 'skipped'; moduleId: string; reason: string };
|
|
@@ -115,34 +115,86 @@ export function classifyVersionChange(installed: string, latest: string): Versio
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Download a
|
|
119
|
-
* the
|
|
120
|
-
*
|
|
118
|
+
* Download a registry package into a temp file. Shared by `fetchAndUpdate`
|
|
119
|
+
* (the full update path) and `fetchTargetManifest` (the upgrade gate that
|
|
120
|
+
* must read the TARGET manifest before it mutates anything). The index entry
|
|
121
|
+
* carries the published sha256; absence is tolerated, matching
|
|
122
|
+
* `fetchAndUpdate`'s original tolerance for entries predating cksum.
|
|
121
123
|
*/
|
|
122
|
-
export async function
|
|
124
|
+
export async function downloadRegistryPackage(
|
|
123
125
|
client: RegistryClient,
|
|
124
126
|
moduleId: string,
|
|
125
127
|
version: string,
|
|
126
|
-
|
|
127
|
-
flags: Record<string, string | boolean>,
|
|
128
|
-
): Promise<UpdateOutcome> {
|
|
128
|
+
): Promise<{ ok: true; tmpPath: string } | { ok: false; error: string }> {
|
|
129
129
|
const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
|
|
130
130
|
try {
|
|
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
131
|
const entries = await client.getIndex(moduleId).catch(() => []);
|
|
136
132
|
const cksum = entries.find((entry) => entry.vers === version)?.cksum;
|
|
137
133
|
const pkgData = await client.download(moduleId, version, cksum);
|
|
138
134
|
await Bun.write(tmpPath, pkgData);
|
|
135
|
+
return { ok: true, tmpPath };
|
|
139
136
|
} catch (err) {
|
|
140
137
|
return {
|
|
141
|
-
|
|
142
|
-
moduleId,
|
|
138
|
+
ok: false,
|
|
143
139
|
error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
144
140
|
};
|
|
145
141
|
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read the manifest of a registry version WITHOUT installing it: download,
|
|
146
|
+
* extract to a temp dir, parse manifest.yml, clean up. Used by the upgrade
|
|
147
|
+
* path to gate on the target's capability requirements before any state
|
|
148
|
+
* changes (celilo#1361). Failure to fetch or parse is not itself fatal — the
|
|
149
|
+
* caller proceeds to `fetchAndUpdate`, which reports the same failure through
|
|
150
|
+
* its own channel.
|
|
151
|
+
*/
|
|
152
|
+
export async function fetchTargetManifest(
|
|
153
|
+
client: RegistryClient,
|
|
154
|
+
moduleId: string,
|
|
155
|
+
version: string,
|
|
156
|
+
): Promise<{ ok: true; manifest: ModuleManifest } | { ok: false }> {
|
|
157
|
+
const downloaded = await downloadRegistryPackage(client, moduleId, version);
|
|
158
|
+
if (!downloaded.ok) return { ok: false };
|
|
159
|
+
try {
|
|
160
|
+
const extractResult = await extractPackage(downloaded.tmpPath);
|
|
161
|
+
if (!extractResult.success || !extractResult.tempDir) return { ok: false };
|
|
162
|
+
try {
|
|
163
|
+
const manifestPath = join(extractResult.tempDir, 'manifest.yml');
|
|
164
|
+
if (!existsSync(manifestPath)) return { ok: false };
|
|
165
|
+
const parsed = parseYaml(readFileSync(manifestPath, 'utf-8'));
|
|
166
|
+
return { ok: true, manifest: ModuleManifestSchema.parse(parsed) };
|
|
167
|
+
} finally {
|
|
168
|
+
await cleanupTempDir(extractResult.tempDir);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return { ok: false };
|
|
172
|
+
} finally {
|
|
173
|
+
try {
|
|
174
|
+
await unlink(downloaded.tmpPath);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Download a module package from the registry into a temp file and run
|
|
181
|
+
* the standard updateOne path against it. Cleans the temp file in a
|
|
182
|
+
* finally block so a mid-flight failure doesn't leak a tar.zst on disk.
|
|
183
|
+
*/
|
|
184
|
+
export async function fetchAndUpdate(
|
|
185
|
+
client: RegistryClient,
|
|
186
|
+
moduleId: string,
|
|
187
|
+
version: string,
|
|
188
|
+
db: ReturnType<typeof getDb>,
|
|
189
|
+
flags: Record<string, string | boolean>,
|
|
190
|
+
): Promise<UpdateOutcome> {
|
|
191
|
+
// The download (index lookup, cksum verification, temp write) is shared
|
|
192
|
+
// with `fetchTargetManifest`, which reads the same package before this
|
|
193
|
+
// update path ever runs.
|
|
194
|
+
const downloaded = await downloadRegistryPackage(client, moduleId, version);
|
|
195
|
+
if (!downloaded.ok) {
|
|
196
|
+
return { status: 'failed', moduleId, error: downloaded.error };
|
|
197
|
+
}
|
|
146
198
|
try {
|
|
147
199
|
// Registry packages are pre-verified at publish time; skip the
|
|
148
200
|
// signature check here to match `module import`'s registry path.
|
|
@@ -153,14 +205,14 @@ export async function fetchAndUpdate(
|
|
|
153
205
|
// log line — without it, output would say "v1.0.0 → v1.0.0" because
|
|
154
206
|
// the manifest semver doesn't include the +N revision.
|
|
155
207
|
return await updateOne(
|
|
156
|
-
tmpPath,
|
|
208
|
+
downloaded.tmpPath,
|
|
157
209
|
db,
|
|
158
210
|
{ ...flags, 'skip-verify': true },
|
|
159
211
|
{ quiet: true, displayVersion: version },
|
|
160
212
|
);
|
|
161
213
|
} finally {
|
|
162
214
|
try {
|
|
163
|
-
await unlink(tmpPath);
|
|
215
|
+
await unlink(downloaded.tmpPath);
|
|
164
216
|
} catch {}
|
|
165
217
|
}
|
|
166
218
|
}
|
|
@@ -475,12 +527,15 @@ export async function updateOne(
|
|
|
475
527
|
|
|
476
528
|
// Update manifest in database. We persist the display version (with
|
|
477
529
|
// +N when known) so subsequent `module list` / `module update` calls
|
|
478
|
-
// see the same version string the registry reported.
|
|
530
|
+
// see the same version string the registry reported. updatedAt moves with
|
|
531
|
+
// the version: a frozen timestamp beside a new version read as "nothing
|
|
532
|
+
// happened here" to the audit (celilo#1363).
|
|
479
533
|
db.update(modules)
|
|
480
534
|
.set({
|
|
481
535
|
manifestData: newManifest as unknown as Record<string, unknown>,
|
|
482
536
|
version: newVersion,
|
|
483
537
|
name: newManifest.name,
|
|
538
|
+
updatedAt: new Date(),
|
|
484
539
|
})
|
|
485
540
|
.where(eq(modules.id, moduleId))
|
|
486
541
|
.run();
|
|
@@ -557,7 +612,7 @@ export async function handleModuleUpdate(
|
|
|
557
612
|
);
|
|
558
613
|
|
|
559
614
|
// Skips that fall under a wildcard expansion (e.g. modules/* picking
|
|
560
|
-
// up `modules/
|
|
615
|
+
// up `modules/__archive__/`) shouldn't even be mentioned — they're not
|
|
561
616
|
// signal. Skips for "module not installed" ARE signal because the
|
|
562
617
|
// user explicitly named the path; surface those.
|
|
563
618
|
const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry-poll capability gate (celilo#1361): `module upgrade` reads the
|
|
3
|
+
* TARGET version's manifest BEFORE updating anything, and defers — without
|
|
4
|
+
* changing any state — when the deployed capability providers cannot serve
|
|
5
|
+
* its requirements.
|
|
6
|
+
*
|
|
7
|
+
* celilo-website burned six release revisions (+1 through +6) on exactly
|
|
8
|
+
* this: every poll attempt updated the stored version, failed in caddy's
|
|
9
|
+
* publish hook ("sourceDir is required" against a provider built before
|
|
10
|
+
* b67c9423), recorded the new version beside the old VERIFIED state, and
|
|
11
|
+
* re-triggered on the next tick. The gate makes that sequence impossible.
|
|
12
|
+
*
|
|
13
|
+
* The RegistryClient is spied (the established pattern in
|
|
14
|
+
* `module-import-registry.test.ts`); the download returns a REAL tar package
|
|
15
|
+
* carrying only a manifest, which is all `fetchTargetManifest` reads.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
19
|
+
import type { Mock } from 'bun:test';
|
|
20
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { writeFile } from 'node:fs/promises';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { create as tarCreate } from 'tar';
|
|
25
|
+
import { getDb } from '../../db/client';
|
|
26
|
+
import { capabilities, modules } from '../../db/schema';
|
|
27
|
+
import type { IndexEntry } from '../../registry/client';
|
|
28
|
+
import { RegistryClient } from '../../registry/client';
|
|
29
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
30
|
+
import { handleModuleUpgrade } from './module-upgrade';
|
|
31
|
+
|
|
32
|
+
function entry(vers: string): IndexEntry {
|
|
33
|
+
return { name: 'celilo-website', vers, deps: [], cksum: 'abc', yanked: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A minimal registry package: a tar whose root is manifest.yml. */
|
|
37
|
+
async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-pkg-'));
|
|
39
|
+
await writeFile(join(dir, 'manifest.yml'), manifestYaml);
|
|
40
|
+
const tarPath = join(dir, 'pkg.netapp');
|
|
41
|
+
await tarCreate({ file: tarPath, cwd: dir, portable: true }, ['manifest.yml']);
|
|
42
|
+
const bytes = await Bun.file(tarPath).arrayBuffer();
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MANIFEST = (publicWebVersion: string) => `
|
|
48
|
+
celilo_contract: "1.0"
|
|
49
|
+
id: celilo-website
|
|
50
|
+
name: Celilo Website
|
|
51
|
+
version: 2.0.0
|
|
52
|
+
requires:
|
|
53
|
+
capabilities:
|
|
54
|
+
- name: public_web
|
|
55
|
+
version: ${publicWebVersion}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
let tempDir: string;
|
|
59
|
+
let getIndexSpy: Mock<(name: string) => Promise<IndexEntry[]>>;
|
|
60
|
+
let downloadSpy: Mock<(name: string, vers: string) => Promise<ArrayBuffer>>;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-test-'));
|
|
64
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
65
|
+
process.env.CELILO_DATA_DIR = tempDir;
|
|
66
|
+
getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([entry('2.0.0')]);
|
|
67
|
+
downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(new ArrayBuffer(0));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
getIndexSpy.mockRestore();
|
|
72
|
+
downloadSpy.mockRestore();
|
|
73
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
74
|
+
resetTestDbPath();
|
|
75
|
+
delete process.env.CELILO_DATA_DIR;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function seedFleet(caddyPublicWebVersion: string): void {
|
|
79
|
+
const db = getDb();
|
|
80
|
+
// sourcePath must be a real writable dir: when the gate does NOT fire, the
|
|
81
|
+
// flow proceeds into updateOne, which stages the new copy beside it.
|
|
82
|
+
const installDir = (id: string): string => {
|
|
83
|
+
const p = join(tempDir, 'modules', id);
|
|
84
|
+
mkdirSync(p, { recursive: true });
|
|
85
|
+
return p;
|
|
86
|
+
};
|
|
87
|
+
db.insert(modules)
|
|
88
|
+
.values({
|
|
89
|
+
id: 'celilo-website',
|
|
90
|
+
name: 'Celilo Website',
|
|
91
|
+
version: '1.0.5',
|
|
92
|
+
manifestData: { id: 'celilo-website', name: 'Celilo Website', version: '1.0.5' },
|
|
93
|
+
sourcePath: installDir('celilo-website'),
|
|
94
|
+
})
|
|
95
|
+
.run();
|
|
96
|
+
db.insert(modules)
|
|
97
|
+
.values({
|
|
98
|
+
id: 'caddy',
|
|
99
|
+
name: 'Caddy',
|
|
100
|
+
version: '2.3.3+1',
|
|
101
|
+
manifestData: { id: 'caddy', name: 'Caddy', version: '2.3.3' },
|
|
102
|
+
sourcePath: installDir('caddy'),
|
|
103
|
+
})
|
|
104
|
+
.run();
|
|
105
|
+
db.insert(capabilities)
|
|
106
|
+
.values({
|
|
107
|
+
moduleId: 'caddy',
|
|
108
|
+
capabilityName: 'public_web',
|
|
109
|
+
version: caddyPublicWebVersion,
|
|
110
|
+
data: {},
|
|
111
|
+
})
|
|
112
|
+
.run();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
|
|
116
|
+
test('defers an upgrade whose requirement no deployed provider serves', async () => {
|
|
117
|
+
seedFleet('3.1.0');
|
|
118
|
+
downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('4.0.0')));
|
|
119
|
+
|
|
120
|
+
const result = await handleModuleUpgrade(['celilo-website'], {});
|
|
121
|
+
|
|
122
|
+
expect(result.success).toBe(false);
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
expect(result.deferred).toBe(true);
|
|
125
|
+
expect(result.error).toContain('deferred');
|
|
126
|
+
expect(result.error).toContain('public_web@4.0.0');
|
|
127
|
+
expect(result.error).toContain('caddy');
|
|
128
|
+
}
|
|
129
|
+
// The point of deferring: NO state changed. The stored version still
|
|
130
|
+
// reads the installed release and will re-read as up-to-date-candidate
|
|
131
|
+
// next tick instead of looking upgraded-but-broken (celilo#1363's shape).
|
|
132
|
+
const row = getDb()
|
|
133
|
+
.select()
|
|
134
|
+
.from(modules)
|
|
135
|
+
.all()
|
|
136
|
+
.find((m) => m.id === 'celilo-website');
|
|
137
|
+
expect(row?.version).toBe('1.0.5');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('does not defer when the deployed provider serves the requirement', async () => {
|
|
141
|
+
seedFleet('3.1.0');
|
|
142
|
+
downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.0.0')));
|
|
143
|
+
|
|
144
|
+
const result = await handleModuleUpgrade(['celilo-website'], {});
|
|
145
|
+
|
|
146
|
+
// Whatever happens downstream (the minimal package has no scripts), the
|
|
147
|
+
// gate itself must not have fired — `deferred` marks a capability wall.
|
|
148
|
+
if (!result.success) {
|
|
149
|
+
expect(result.deferred).toBeUndefined();
|
|
150
|
+
} else {
|
|
151
|
+
expect(result.success).toBe(true);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,5 +1,16 @@
|
|
|
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,
|
|
@@ -8,6 +19,7 @@ import {
|
|
|
8
19
|
pickAutoUpgrade,
|
|
9
20
|
pickUpgradePolicy,
|
|
10
21
|
selectPollTargets,
|
|
22
|
+
upgradeOneModule,
|
|
11
23
|
} from './module-upgrade';
|
|
12
24
|
|
|
13
25
|
/** Minimal manifest fixture; only `hooks` matters for the backup gate. */
|
|
@@ -70,6 +82,7 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
|
|
|
70
82
|
installed: '1.0.0',
|
|
71
83
|
latest: '1.0.1',
|
|
72
84
|
autoUpgrade: true,
|
|
85
|
+
inError: false,
|
|
73
86
|
...over,
|
|
74
87
|
});
|
|
75
88
|
|
|
@@ -92,6 +105,13 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
|
|
|
92
105
|
expect(selectPollTargets([base({ latest: null })])).toEqual([]);
|
|
93
106
|
});
|
|
94
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
|
+
|
|
95
115
|
test('picks only the eligible subset from a mixed set', () => {
|
|
96
116
|
const targets = selectPollTargets([
|
|
97
117
|
base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }),
|
|
@@ -136,3 +156,97 @@ describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifes
|
|
|
136
156
|
expect(needsPreUpgradeBackup('safe', manifest(undefined))).toBe(false);
|
|
137
157
|
});
|
|
138
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
|
+
});
|