diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index da87a2b3516..ff5363e7ca2 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1520,6 +1520,72 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) return configFile; }, + '4.0.0-rc.3': (configFile) => { + // Re-sync Drive ABCI and rs-dapi docker images that dashmate + // 3.0.x → 4.0.0-rc.2 left at the protocol-11 default tag + // (`dashpay/drive:3`, `dashpay/rs-dapi:3`), causing nodes to + // crash-loop after protocol 12 activation (#3889). + // + // Pre-3.0.0 → 3.0.0 already re-synced these images, but nodes + // already on 3.0.x skipped the 3.0.0 step (semver.gt filter), + // and the 3.0.1 / 3.0.2 / 3.1.0 migrations only touched + // Core / Gateway / Tenderdash images. + // + // The original attempt (#3889) keyed this migration at the + // same version it shipped with (4.0.0-rc.2), so + // already-stamped 4.0.0-rc.2 configs short-circuit in + // migrateConfigFile (fromVersion === toVersion). Re-keying at + // the next release ensures the fix fires for both: + // • 3.0.x → 4.0.0-rc.x upgrades (the loop runs every + // migration with key > fromVersion, regardless of + // toVersion), and + // • 4.0.0-rc.2 → 4.0.0-rc.3 once a chore(release) bumps + // packages/dashmate/package.json (the convention; cf. + // #3794 + #3796 for the 3.0.2 Envoy CVE). + // + // Only rewrite the stale dashmate-shipped tags — same style + // as the 3.0.2 Envoy CVE migration. A custom / private / + // manually-corrected image (private fork, vendor-patched + // build, `:latest`, etc.) is left untouched. + // The 3.x line shipped four prerelease label series: + // `3.0.0-dev.X` / `3.1.0-dev.X` → `:3-dev` + // `3.0.0-rc.X` → `:3-rc` + // `3.0.1-hotfix.{1..4}` / + // `3.1.0-hotfix.1` → `:3-hotfix` + // stable 3.0.0 / 3.0.1 / 3.0.2 / 3.1.0 → `:3` + // All of them resolve to a protocol-11 image and crash-loop + // after protocol 12 activation. Keep the alternation tight so + // custom tags (`:3-mycorp`, `:3.0.0`, `:latest`, etc.) survive. + const isStaleDriveImage = (image) => ( + typeof image === 'string' + && /^dashpay\/drive:3(?:-(?:dev|rc|hotfix))?$/.test(image) + ); + const isStaleRsDapiImage = (image) => ( + typeof image === 'string' + && /^dashpay\/rs-dapi:3(?:-(?:dev|rc|hotfix))?$/.test(image) + ); + + Object.entries(configFile.configs) + .forEach(([name, options]) => { + const defaultConfig = getDefaultConfigByNameOrGroup(name, options.group); + + const driveDocker = options.platform?.drive?.abci?.docker; + if (driveDocker + && isStaleDriveImage(driveDocker.image) + && defaultConfig.has('platform.drive.abci.docker.image')) { + driveDocker.image = defaultConfig.get('platform.drive.abci.docker.image'); + } + + const rsDapiDocker = options.platform?.dapi?.rsDapi?.docker; + if (rsDapiDocker + && isStaleRsDapiImage(rsDapiDocker.image) + && defaultConfig.has('platform.dapi.rsDapi.docker.image')) { + rsDapiDocker.image = defaultConfig.get('platform.dapi.rsDapi.docker.image'); + } + }); + + return configFile; + }, }; } diff --git a/packages/dashmate/test/unit/config/configFile/migrate3xTo4xRcImages.spec.js b/packages/dashmate/test/unit/config/configFile/migrate3xTo4xRcImages.spec.js new file mode 100644 index 00000000000..5484a7e5c21 --- /dev/null +++ b/packages/dashmate/test/unit/config/configFile/migrate3xTo4xRcImages.spec.js @@ -0,0 +1,326 @@ +import path from 'path'; +import DefaultConfigs from '../../../../src/config/DefaultConfigs.js'; +import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; +import getLocalConfigFactory from '../../../../configs/defaults/getLocalConfigFactory.js'; +import getTestnetConfigFactory from '../../../../configs/defaults/getTestnetConfigFactory.js'; +import getMainnetConfigFactory from '../../../../configs/defaults/getMainnetConfigFactory.js'; +import getConfigFileMigrationsFactory from '../../../../configs/getConfigFileMigrationsFactory.js'; +import migrateConfigFileFactory from '../../../../src/config/configFile/migrateConfigFileFactory.js'; + +// Regression coverage for dashpay/platform#3889 and its rework. +// +// A node already on 3.0.1 skips the 3.0.0 migration (semver.gt filter) +// that used to re-sync Drive ABCI and rs-dapi images. The intervening +// 3.0.1 / 3.0.2 / 3.1.0 migrations only touched Core / Gateway / Tenderdash +// `.docker.image` fields. The result: a `dashmate update 3.0.x → 4.0.0-rc.x` +// kept the protocol-11 images (`dashpay/drive:3`, `dashpay/rs-dapi:3`) +// and the node crash-looped after protocol 12 activation. +// +// The original fix keyed the migration at 4.0.0-rc.2 — the same version +// dashmate already shipped — so any node already stamped +// configFormatVersion: "4.0.0-rc.2" short-circuited in migrateConfigFile +// (fromVersion === toVersion) and never ran the migration. The rework +// re-keys it at 4.0.0-rc.3 (the next release) and adds a stale-shipped- +// image predicate to preserve customised images. +describe('migration 4.0.0-rc.3: re-sync Drive ABCI & rs-dapi images (#3889)', () => { + const STALE_DRIVE = 'dashpay/drive:3'; + const STALE_DRIVE_DEV = 'dashpay/drive:3-dev'; + const STALE_DRIVE_RC = 'dashpay/drive:3-rc'; + const STALE_DRIVE_HOTFIX = 'dashpay/drive:3-hotfix'; + const STALE_RS_DAPI = 'dashpay/rs-dapi:3'; + const STALE_RS_DAPI_DEV = 'dashpay/rs-dapi:3-dev'; + const STALE_RS_DAPI_RC = 'dashpay/rs-dapi:3-rc'; + const STALE_RS_DAPI_HOTFIX = 'dashpay/rs-dapi:3-hotfix'; + const CUSTOM_DRIVE = 'private-registry.internal/drive:patched-v3'; + const CUSTOM_RS_DAPI = 'my-org/rs-dapi:fork-3'; + + let migrate; + let expectedDriveImage; + let expectedRsDapiImage; + + beforeEach(() => { + // Construct the migration directly (no DI). The full DI container + // transitively imports `@dashevo/dapi-client`, which can fail to + // load when `@dashevo/wasm-dpp` hasn't been built; the migration + // itself only needs the default-config factories. + const homeDirStub = { + joinPath: (...segments) => path.join('/tmp/dashmate-spec', ...segments), + }; + const getBaseConfig = getBaseConfigFactory(); + const defaults = new DefaultConfigs([ + getBaseConfig, + getLocalConfigFactory(getBaseConfig), + getTestnetConfigFactory(homeDirStub, getBaseConfig), + getMainnetConfigFactory(homeDirStub, getBaseConfig), + ]); + const getMigrations = getConfigFileMigrationsFactory(homeDirStub, defaults); + migrate = migrateConfigFileFactory(getMigrations); + + expectedDriveImage = defaults.get('base').get('platform.drive.abci.docker.image'); + expectedRsDapiImage = defaults.get('base').get('platform.dapi.rsDapi.docker.image'); + }); + + function buildConfig({ + network, group, driveImage, rsDapiImage, + }) { + return { + group, + network, + platform: { + enable: true, + drive: { + abci: { + docker: { image: driveImage }, + }, + }, + dapi: { + rsDapi: { + docker: { image: rsDapiImage }, + }, + }, + }, + }; + } + + // 3.0.x and 3.1.0 are real-world entry points: a node could be on 3.0.1 + // (the version that originally skipped the 3.0.0 image re-sync — the + // direct #3889 repro) or on 3.1.0 (skipping it for the same reason). + // Either way the new migration must converge them — and crucially, the + // loop runs every migration with key > fromVersion, so this fires even + // when toVersion is still 4.0.0-rc.2 (the version currently in + // packages/dashmate/package.json). + [ + { fromVersion: '3.0.1', toVersion: '4.0.0-rc.2' }, + { fromVersion: '3.1.0', toVersion: '4.0.0-rc.2' }, + { fromVersion: '3.0.1', toVersion: '4.0.0-rc.3' }, + { fromVersion: '3.1.0', toVersion: '4.0.0-rc.3' }, + ].forEach(({ fromVersion, toVersion }) => { + it(`re-syncs stale drive.abci and dapi.rsDapi images on ${fromVersion} → ${toVersion}`, () => { + const rawConfigFile = { + configFormatVersion: fromVersion, + defaultConfigName: null, + defaultGroupName: null, + configs: { + testnet: buildConfig({ + network: 'testnet', + group: 'testnet', + driveImage: STALE_DRIVE, + rsDapiImage: STALE_RS_DAPI, + }), + mainnet: buildConfig({ + network: 'mainnet', + group: 'mainnet', + driveImage: STALE_DRIVE, + rsDapiImage: STALE_RS_DAPI, + }), + }, + }; + + const migrated = migrate(rawConfigFile, fromVersion, toVersion); + + ['testnet', 'mainnet'].forEach((name) => { + const { docker: driveDocker } = migrated.configs[name].platform.drive.abci; + const { docker: rsDapiDocker } = migrated.configs[name].platform.dapi.rsDapi; + + expect(driveDocker.image).to.equal(expectedDriveImage); + expect(rsDapiDocker.image).to.equal(expectedRsDapiImage); + + // Pin against the specific regression so the test stays + // meaningful even if a future default happens to match + // the stale value again. + expect(driveDocker.image).to.not.equal(STALE_DRIVE); + expect(rsDapiDocker.image).to.not.equal(STALE_RS_DAPI); + }); + + expect(migrated.configFormatVersion).to.equal(toVersion); + }); + }); + + // All the prerelease label series the 3.x line shipped derive from + // semver.prerelease(version)[0] in getBaseConfigFactory.js, producing + // these stale tags. The hotfix variant in particular came from + // 3.0.1-hotfix.{1..4} (#3020/#3044/#3055/#3060) and 3.1.0-hotfix.1 — + // an early oversight in the predicate missed it. + [ + { label: 'dev-series', drive: STALE_DRIVE_DEV, rsDapi: STALE_RS_DAPI_DEV }, + { label: 'rc-series', drive: STALE_DRIVE_RC, rsDapi: STALE_RS_DAPI_RC }, + { label: 'hotfix-series', drive: STALE_DRIVE_HOTFIX, rsDapi: STALE_RS_DAPI_HOTFIX }, + ].forEach(({ label, drive, rsDapi }) => { + it(`re-syncs the ${label} stale tags (${drive}, ${rsDapi})`, () => { + const rawConfigFile = { + configFormatVersion: '3.1.0', + defaultConfigName: null, + defaultGroupName: null, + configs: { + testnet: buildConfig({ + network: 'testnet', + group: 'testnet', + driveImage: drive, + rsDapiImage: rsDapi, + }), + }, + }; + + const migrated = migrate(rawConfigFile, '3.1.0', '4.0.0-rc.3'); + + expect(migrated.configs.testnet.platform.drive.abci.docker.image) + .to.equal(expectedDriveImage); + expect(migrated.configs.testnet.platform.dapi.rsDapi.docker.image) + .to.equal(expectedRsDapiImage); + }); + }); + + // The exact case the rework targets: a node that already upgraded to + // 4.0.0-rc.2 binary kept the stale dashpay/drive:3 / dashpay/rs-dapi:3 + // tags and got configFormatVersion: "4.0.0-rc.2" stamped on disk. + // After a chore(release) bumps packages/dashmate/package.json to + // 4.0.0-rc.3, the next dashmate load enters migrateConfigFile with + // fromVersion=4.0.0-rc.2 / toVersion=4.0.0-rc.3, and the new + // migration finally runs (the original 4.0.0-rc.2 key would have + // been short-circuited by semver.gt('4.0.0-rc.2', '4.0.0-rc.2')). + it('re-syncs already-stamped 4.0.0-rc.2 configs on the rc.3 release bump', () => { + const rawConfigFile = { + configFormatVersion: '4.0.0-rc.2', + defaultConfigName: null, + defaultGroupName: null, + configs: { + mainnet: buildConfig({ + network: 'mainnet', + group: 'mainnet', + driveImage: STALE_DRIVE, + rsDapiImage: STALE_RS_DAPI, + }), + }, + }; + + const migrated = migrate(rawConfigFile, '4.0.0-rc.2', '4.0.0-rc.3'); + + expect(migrated.configs.mainnet.platform.drive.abci.docker.image) + .to.equal(expectedDriveImage); + expect(migrated.configs.mainnet.platform.dapi.rsDapi.docker.image) + .to.equal(expectedRsDapiImage); + expect(migrated.configFormatVersion).to.equal('4.0.0-rc.3'); + }); + + // Documents the bound of what migration alone can fix: as long as + // packages/dashmate/package.json sits at 4.0.0-rc.2, a config already + // stamped 4.0.0-rc.2 short-circuits in migrateConfigFile before any + // entry can run. This is the structural limitation that motivated + // re-keying the migration at the *next* release rather than reusing + // 4.0.0-rc.2 — and why the full rollout depends on a follow-up + // chore(release) PR bumping the package version (same flow as the + // 3.0.2 Envoy CVE: #3794 added the migration, #3796 cut the release). + it('cannot reach a 4.0.0-rc.2 stale config while toVersion is still 4.0.0-rc.2 (release-bump prerequisite)', () => { + const rawConfigFile = { + configFormatVersion: '4.0.0-rc.2', + defaultConfigName: null, + defaultGroupName: null, + configs: { + mainnet: buildConfig({ + network: 'mainnet', + group: 'mainnet', + driveImage: STALE_DRIVE, + rsDapiImage: STALE_RS_DAPI, + }), + }, + }; + + const migrated = migrate(rawConfigFile, '4.0.0-rc.2', '4.0.0-rc.2'); + + // No migration runs — config returned untouched. + expect(migrated.configs.mainnet.platform.drive.abci.docker.image).to.equal(STALE_DRIVE); + expect(migrated.configs.mainnet.platform.dapi.rsDapi.docker.image).to.equal(STALE_RS_DAPI); + expect(migrated.configFormatVersion).to.equal('4.0.0-rc.2'); + }); + + // Custom-image preservation. The original 4.0.0-rc.2 migration + // unconditionally overwrote every drive/rsDapi image with the + // current default, clobbering private forks, vendor-patched builds, + // and `:latest` pins. The rework matches only the stale shipped + // defaults — same style as the 3.0.2 Envoy CVE migration that + // rewrote only `dashpay/envoy:1.30.*`. + it('preserves custom Drive / rs-dapi images', () => { + const rawConfigFile = { + configFormatVersion: '3.1.0', + defaultConfigName: null, + defaultGroupName: null, + configs: { + mainnet: buildConfig({ + network: 'mainnet', + group: 'mainnet', + driveImage: CUSTOM_DRIVE, + rsDapiImage: CUSTOM_RS_DAPI, + }), + }, + }; + + const migrated = migrate(rawConfigFile, '3.1.0', '4.0.0-rc.3'); + + expect(migrated.configs.mainnet.platform.drive.abci.docker.image).to.equal(CUSTOM_DRIVE); + expect(migrated.configs.mainnet.platform.dapi.rsDapi.docker.image).to.equal(CUSTOM_RS_DAPI); + }); + + // Mixed config: one config has stale dashmate defaults (gets rewritten), + // a sibling has a custom image (preserved). Exercises the same predicate + // pattern as the 3.0.2 Envoy CVE migration end-to-end. + it('only rewrites the stale config when siblings have custom images', () => { + const rawConfigFile = { + configFormatVersion: '3.1.0', + defaultConfigName: null, + defaultGroupName: null, + configs: { + testnet: buildConfig({ + network: 'testnet', + group: 'testnet', + driveImage: STALE_DRIVE, + rsDapiImage: STALE_RS_DAPI, + }), + mainnet: buildConfig({ + network: 'mainnet', + group: 'mainnet', + driveImage: CUSTOM_DRIVE, + rsDapiImage: CUSTOM_RS_DAPI, + }), + }, + }; + + const migrated = migrate(rawConfigFile, '3.1.0', '4.0.0-rc.3'); + + expect(migrated.configs.testnet.platform.drive.abci.docker.image) + .to.equal(expectedDriveImage); + expect(migrated.configs.testnet.platform.dapi.rsDapi.docker.image) + .to.equal(expectedRsDapiImage); + + expect(migrated.configs.mainnet.platform.drive.abci.docker.image).to.equal(CUSTOM_DRIVE); + expect(migrated.configs.mainnet.platform.dapi.rsDapi.docker.image).to.equal(CUSTOM_RS_DAPI); + }); + + it('no-ops on configs without platform.dapi.rsDapi', () => { + const rawConfigFile = { + configFormatVersion: '3.1.0', + defaultConfigName: null, + defaultGroupName: null, + configs: { + mainnet: { + group: 'mainnet', + network: 'mainnet', + platform: { + enable: true, + drive: { + abci: { + docker: { image: STALE_DRIVE }, + }, + }, + dapi: {}, + }, + }, + }, + }; + + const migrated = migrate(rawConfigFile, '3.1.0', '4.0.0-rc.3'); + + expect(migrated.configs.mainnet.platform.drive.abci.docker.image) + .to.equal(expectedDriveImage); + expect(migrated.configs.mainnet.platform.dapi.rsDapi).to.equal(undefined); + }); +});