@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
366 lines • 20.8 kB
JavaScript
// 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