UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

366 lines 20.8 kB
// SPDX-License-Identifier: Apache-2.0 import { afterEach, beforeEach, describe, it } from 'mocha'; import { expect } from 'chai'; import fs from 'node:fs'; import { ComponentUpgradeMigrationRules } from '../../../../src/commands/migrations/component-upgrade-rules.js'; import * as constants from '../../../../src/core/constants.js'; import sinon from 'sinon'; describe('ComponentUpgradeMigrationRules.planUpgradeMigrationPath', () => { let existsSyncStub; let readFileSyncStub; beforeEach(() => { ComponentUpgradeMigrationRules.resetCache(); existsSyncStub = sinon.stub(fs, 'existsSync'); readFileSyncStub = sinon.stub(fs, 'readFileSync'); existsSyncStub.callThrough(); readFileSyncStub.callThrough(); }); afterEach(() => { ComponentUpgradeMigrationRules.resetCache(); sinon.restore(); }); describe('same version (no-op upgrade)', () => { it('returns a single in-place step for same version', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.27.0', '0.27.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); expect(steps[0].fromVersion).to.equal('0.27.0'); expect(steps[0].toVersion).to.equal('0.27.0'); }); }); describe('downgrade (no forward boundary crossing)', () => { it('returns a single in-place step when downgrading', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.1', '0.27.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); expect(steps[0].fromVersion).to.equal('0.28.1'); expect(steps[0].toVersion).to.equal('0.27.0'); }); }); describe('upgrade not crossing a boundary', () => { it('returns a single in-place step when already past the 0.28.1 boundary', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.1', '0.29.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); expect(steps[0].fromVersion).to.equal('0.28.1'); expect(steps[0].toVersion).to.equal('0.29.0'); }); it('returns a single in-place step for a larger upgrade already past 0.28.1', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.1', '0.35.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); expect(steps[0].fromVersion).to.equal('0.28.1'); expect(steps[0].toVersion).to.equal('0.35.0'); }); }); describe('upgrade crossing the 0.28.1 boundary', () => { it('returns a single recreate step when upgrading from below to exactly 0.28.1', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('0.28.0'); expect(steps[0].toVersion).to.equal('0.28.1'); }); it('returns a single recreate step going directly to target when crossing 0.28.1', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.35.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('0.28.0'); expect(steps[0].toVersion).to.equal('0.35.0'); }); }); describe('upgrade crossing a custom boundary', () => { it('returns a single recreate step when upgrading across a custom recreate boundary', () => { const customConfig = { components: { 'block-node': { defaultStrategy: 'in-place', boundaries: [{ version: '1.0.0', strategy: 'recreate', reason: 'Custom boundary' }], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.9.0', '1.0.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('0.9.0'); expect(steps[0].toVersion).to.equal('1.0.0'); }); it('returns a recreate step going directly to target when crossing a custom boundary', () => { const customConfig = { components: { 'block-node': { defaultStrategy: 'in-place', boundaries: [{ version: '1.0.0', strategy: 'recreate', reason: 'Custom boundary' }], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.9.0', '1.5.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('0.9.0'); expect(steps[0].toVersion).to.equal('1.5.0'); }); it('splits into multiple steps when multiple boundaries with different strategies are crossed', () => { const customConfig = { components: { 'multi-boundary': { defaultStrategy: 'in-place', boundaries: [ { version: '2.0.0', strategy: 'recreate', reason: 'Immutable field change at 2.0.0' }, { version: '3.0.0', strategy: 'in-place', reason: 'Config change at 3.0.0' }, ], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('multi-boundary', '1.0.0', '4.0.0'); expect(steps).to.have.length(2); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('1.0.0'); expect(steps[0].toVersion).to.equal('2.0.0'); expect(steps[1].strategy).to.equal('in-place'); expect(steps[1].fromVersion).to.equal('2.0.0'); expect(steps[1].toVersion).to.equal('4.0.0'); }); it('merges consecutive boundaries with the same strategy into a single step', () => { const customConfig = { components: { 'merge-test': { defaultStrategy: 'in-place', boundaries: [ { version: '2.0.0', strategy: 'recreate', reason: 'First recreate' }, { version: '3.0.0', strategy: 'recreate', reason: 'Second recreate' }, ], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('merge-test', '1.0.0', '4.0.0'); // Both recreate boundaries should merge into a single step jumping to the target expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('1.0.0'); expect(steps[0].toVersion).to.equal('4.0.0'); }); it('handles three alternating-strategy boundaries correctly', () => { const customConfig = { components: { alternating: { defaultStrategy: 'in-place', boundaries: [ { version: '2.0.0', strategy: 'recreate', reason: 'Recreate at 2.0' }, { version: '3.0.0', strategy: 'in-place', reason: 'In-place at 3.0' }, { version: '4.0.0', strategy: 'recreate', reason: 'Recreate at 4.0' }, ], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('alternating', '1.0.0', '5.0.0'); // 3 distinct strategy segments: recreate → in-place → recreate expect(steps).to.have.length(3); expect(steps[0]).to.deep.include({ fromVersion: '1.0.0', toVersion: '2.0.0', strategy: 'recreate' }); expect(steps[1]).to.deep.include({ fromVersion: '2.0.0', toVersion: '3.0.0', strategy: 'in-place' }); expect(steps[2]).to.deep.include({ fromVersion: '3.0.0', toVersion: '5.0.0', strategy: 'recreate' }); }); it('only crosses boundaries within the upgrade range, not all boundaries', () => { const customConfig = { components: { 'partial-cross': { defaultStrategy: 'in-place', boundaries: [ { version: '2.0.0', strategy: 'recreate', reason: 'Recreate at 2.0' }, { version: '5.0.0', strategy: 'recreate', reason: 'Recreate at 5.0' }, ], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); // Upgrading 1.0→3.0 only crosses 2.0, not 5.0 const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('partial-cross', '1.0.0', '3.0.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('1.0.0'); expect(steps[0].toVersion).to.equal('3.0.0'); }); }); describe('unknown component', () => { it('returns a single in-place step with default strategy for unknown component', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('unknown-component', '1.0.0', '2.0.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); expect(steps[0].fromVersion).to.equal('1.0.0'); expect(steps[0].toVersion).to.equal('2.0.0'); }); }); describe('custom config file override', () => { it('loads custom config from file and applies its boundary rules', () => { const customConfig = { components: { 'my-component': { defaultStrategy: 'in-place', boundaries: [ { version: '2.0.0', strategy: 'recreate', reason: 'Breaking change at 2.0.0', }, ], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('my-component', '1.0.0', '2.0.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('1.0.0'); expect(steps[0].toVersion).to.equal('2.0.0'); }); it('falls back to safe empty config if the file has invalid JSON', () => { existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns('not valid json'); // Falls back to the safe empty config, so the upgrade uses the default in-place strategy. const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); }); it('falls back to safe empty config if the file is missing the components field', () => { existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify({ notComponents: {} })); // Falls back to the safe empty config, so the upgrade uses the default in-place strategy. const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); }); }); describe('step metadata', () => { it('includes reason text in migration steps', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1'); expect(steps[0].reason).to.be.a('string').and.not.equal(''); }); it('includes extraCommandArgs array in migration steps', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1'); expect(steps[0].extraCommandArgs).to.be.an('array'); }); it('propagates boundary-specific extraCommandArgs to the migration step', () => { const customConfig = { components: { 'args-test': { defaultStrategy: 'in-place', boundaries: [ { version: '2.0.0', strategy: 'recreate', reason: 'Breaking change', extraCommandArgs: ['--set', 'migration.enabled=true'], }, ], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('args-test', '1.0.0', '3.0.0'); expect(steps[0].extraCommandArgs).to.deep.equal(['--set', 'migration.enabled=true']); }); it('propagates defaultExtraCommandArgs to default-strategy steps', () => { const customConfig = { components: { 'default-args-test': { defaultStrategy: 'in-place', defaultExtraCommandArgs: ['--timeout', '600s'], boundaries: [], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('default-args-test', '1.0.0', '2.0.0'); expect(steps[0].extraCommandArgs).to.deep.equal(['--timeout', '600s']); }); }); describe('version normalization', () => { it('handles versions with v prefix', () => { const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', 'v0.28.0', 'v0.28.1'); // v0.28.0→v0.28.1 crosses the 0.28.1 recreate boundary after stripping the 'v' prefix expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].fromVersion).to.equal('0.28.0'); expect(steps[0].toVersion).to.equal('0.28.1'); }); it('handles pre-release versions correctly', () => { // 0.28.1-rc.1 < 0.28.1 in semver, so the boundary at 0.28.1 is NOT crossed const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1-rc.1'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('in-place'); }); }); describe('default strategy override', () => { it('uses recreate as default strategy when configured', () => { const customConfig = { components: { 'recreate-default': { defaultStrategy: 'recreate', boundaries: [], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('recreate-default', '1.0.0', '2.0.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); }); it('uses recreate default strategy for downgrade when configured', () => { const customConfig = { components: { 'recreate-default': { defaultStrategy: 'recreate', boundaries: [], }, }, }; existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(true); readFileSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE, 'utf8').returns(JSON.stringify(customConfig)); const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('recreate-default', '2.0.0', '1.0.0'); expect(steps).to.have.length(1); expect(steps[0].strategy).to.equal('recreate'); expect(steps[0].reason).to.equal('No forward upgrade boundary crossing detected'); }); }); describe('config caching', () => { it('caches config after first load and does not re-read file', () => { existsSyncStub.withArgs(constants.UPGRADE_MIGRATIONS_FILE).returns(false); // First call loads and caches the default config ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.27.0', '0.28.0'); // Second call should use cached config ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.27.0', '0.28.0'); // existsSync should only be called once for the migration file const migrationFileCalls = existsSyncStub .getCalls() .filter((call) => call.args[0] === constants.UPGRADE_MIGRATIONS_FILE); expect(migrationFileCalls).to.have.length(1); }); }); describe('downgrade across boundary', () => { it('does not apply boundary rules when downgrading across a boundary version', () => { // Downgrading from 0.28.1 to 0.26.0 crosses the 0.28.0 boundary backwards, // but boundaries only apply to forward upgrades const steps = ComponentUpgradeMigrationRules.planUpgradeMigrationPath('block-node', '0.28.1', '0.26.0'); expect(steps).to.have.length(1); // Should use default strategy, NOT recreate from the boundary expect(steps[0].strategy).to.equal('in-place'); expect(steps[0].reason).to.equal('No forward upgrade boundary crossing detected'); }); }); }); //# sourceMappingURL=component-upgrade-rules.test.js.map