diff --git a/packages/dd-trace/test/plugins/versions.spec.js b/packages/dd-trace/test/plugins/versions.spec.js new file mode 100644 index 0000000000..ea35ea1da4 --- /dev/null +++ b/packages/dd-trace/test/plugins/versions.spec.js @@ -0,0 +1,142 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') +const { coerce, major } = require('semver') + +const { getVersionList, resolvePluginVersions } = require('./versions') + +const latests = require('./versions/package.json').dependencies + +const keys = (name, versions, nonConsecutive) => + getVersionList(name, versions, nonConsecutive).map(({ versionKey }) => versionKey) + +const latestMajorKey = name => String(major(coerce(latests[name]))) + +describe('getVersionList', () => { + it('collapses the wildcard to the latest major', () => { + assert.deepEqual(keys('mongodb', ['*']), [latestMajorKey('mongodb')]) + }) + + it('collapses equivalent exact-version notations to a single key', () => { + assert.deepEqual(keys('mongodb', ['1.2.3', '=1.2.3', 'v1.2.3']), ['1.2.3']) + }) + + it('pins the floor and the major for a single-major range', () => { + // `<3` caps the range to major 2, so only the pinned floor and the latest of major 2 are keys. + assert.deepEqual(keys('mongodb', ['>=2 <3']), ['2.0.0', '2']) + }) + + it('covers the floor major and every major up to the range top', () => { + // `<5` caps the top at major 4; the floor (2.0.0) is pinned and majors 2-4 each resolve to their latest. + assert.deepEqual(keys('mongodb', ['>=2 <5']), ['2.0.0', '2', '3', '4']) + }) + + it('covers every major from the floor to the capped top', () => { + // `<6` caps the top at major 5; the floor major's latest (1) is covered too, not only the newest of the range. + assert.deepEqual(keys('mongodb', ['>=1 <6']), ['1.0.0', '1', '2', '3', '4', '5']) + }) + + it('de-duplicates a shared floor across multiple ranges', () => { + assert.deepEqual(keys('mongodb', ['>=2 <3', '^2.0.0']), ['2.0.0', '2']) + }) + + it('consolidates the floor with the top major when the floor is the pinned latest', () => { + // `>=` floors at the newest version, so the bare top-major key resolves to that same version and is + // dropped; the pinned package.json is what proves the two keys identical. + assert.deepEqual(keys('mongodb', [`>=${latests.mongodb}`]), [latests.mongodb]) + }) + + it('adds the floor, the floor major and the top major for non-consecutive packages', () => { + // The middle majors may be unpublished, but the floor major (1) and the top major (5) both exist and are tested. + assert.deepEqual(keys('mongodb', ['>=1 <6'], new Set(['mongodb'])), ['1.0.0', '1', '5']) + }) + + it('treats the built-in non-consecutive packages as floor + floor major + top major', () => { + // graphql jumps from 0.x to 14.x; 1.x–13.x are skipped, but the latest 0.x and the newest major are still tested. + assert.deepEqual(keys('graphql', ['>=0.10']), ['0.10.0', '0', latestMajorKey('graphql')]) + }) + + it('throws on an unparseable range', () => { + assert.throws(() => getVersionList('mongodb', ['not-a-version']), /Invalid version range/) + }) + + it('throws on an empty entry', () => { + assert.throws(() => getVersionList('mongodb', ['', '>=2 <3']), /Empty version entry/) + }) +}) + +describe('resolvePluginVersions', () => { + const versionKeys = result => result.versionList.map(({ versionKey }) => versionKey) + + it('expands the declared versions and points the unversioned folder at the newest in-scope key', () => { + const result = resolvePluginVersions({ name: 'mongodb', declaredVersions: ['>=2 <5'], env: {} }) + + assert.deepEqual(versionKeys(result), ['2.0.0', '2', '3', '4']) + assert.equal(result.unversioned, '4') + }) + + it('filters the installed keys by RANGE and follows the filtered tail', () => { + const result = resolvePluginVersions({ + name: 'mongodb', + declaredVersions: ['>=1 <6'], + env: { RANGE: '>=2.0.0 <4.0.0' }, + }) + + assert.deepEqual(versionKeys(result), ['2', '3']) + assert.equal(result.unversioned, '3') + }) + + it('replaces the declared versions with PACKAGE_VERSION_RANGE when the module is honoured', () => { + const result = resolvePluginVersions({ + name: 'mongodb', + declaredVersions: ['>=2 <5'], + env: { PACKAGE_VERSION_RANGE: '>=3 <4' }, + }) + + assert.deepEqual(versionKeys(result), ['3.0.0', '3']) + assert.equal(result.unversioned, '>=3 <4') + }) + + it('ignores PACKAGE_VERSION_RANGE for a sibling external that must not be sharded', () => { + const result = resolvePluginVersions({ + name: 'mongodb', + declaredVersions: ['>=2 <5'], + honourEnvRange: false, + env: { PACKAGE_VERSION_RANGE: '>=3 <4' }, + }) + + assert.deepEqual(versionKeys(result), ['2.0.0', '2', '3', '4']) + assert.equal(result.unversioned, '4') + }) + + it('keeps the unversioned folder on the raw shard while RANGE narrows the installed keys', () => { + const result = resolvePluginVersions({ + name: 'mongodb', + declaredVersions: ['>=1 <6'], + env: { PACKAGE_VERSION_RANGE: '>=2 <5', RANGE: '>=3.0.0 <4.0.0' }, + }) + + assert.deepEqual(versionKeys(result), ['3']) + assert.equal(result.unversioned, '>=2 <5') + }) + + it('reports nothing in scope when no version is declared', () => { + const result = resolvePluginVersions({ name: 'mongodb', declaredVersions: [], env: {} }) + + assert.deepEqual(result.versionList, []) + assert.equal(result.unversioned, undefined) + }) + + it('reports nothing in scope when RANGE excludes every declared key', () => { + const result = resolvePluginVersions({ + name: 'mongodb', + declaredVersions: ['>=2 <3'], + env: { RANGE: '>=9.0.0 <10.0.0' }, + }) + + assert.deepEqual(result.versionList, []) + assert.equal(result.unversioned, undefined) + }) +}) diff --git a/packages/dd-trace/test/plugins/versions/index.js b/packages/dd-trace/test/plugins/versions/index.js index 5a249c76ed..b856fd5c41 100644 --- a/packages/dd-trace/test/plugins/versions/index.js +++ b/packages/dd-trace/test/plugins/versions/index.js @@ -1,10 +1,20 @@ 'use strict' -const { subset } = require('semver') +const { clean, coerce, intersects, subset } = require('semver') + const latests = require('./package.json').dependencies const exactVersionExp = /^=?\d+\.\d+\.\d+/ +// Packages whose published majors are not contiguous. `getVersionList()` must not auto-fill their in-between majors: +// the missing majors were never published, so installing them fails. A failing install of a multi-major range is the +// signal to add a package here (the install script's error points back to this list). New entries must explain the gap +// so the next reader does not "fix" it by removing the entry. +const nonConsecutiveMajorPackages = new Set([ + 'graphql', // jumps from 0.x straight to 14.x (no 1.x–13.x); 14.x–17.x are covered via the apollo externals entries + '@redis/client', // jumps from 2.x to 5.x (no 3.x–4.x) +]) + /** * @param {string} name * @param {string} range @@ -40,6 +50,150 @@ function capSubrange (name, subrange) { return `${subrange} <=${latests[name]}` } +/** + * Expand a module's declared version entries into the de-duplicated set of version keys to install and test. Each key + * maps to a `versions/@` workspace folder; the install script and `withVersions()` share this so the set of + * installed folders and the set of tested folders never drift apart. + * + * Per declared range this yields the lowest supported version (pinned exactly) and the newest version of every major + * the range spans, keyed by the bare major so the key resolves to that major's latest. Covering each major explicitly + * (rather than emitting the raw range, which resolves only to the newest version of the whole range) makes sure the + * floor major's latest is tested too. The top major is derived from the pinned latest in `package.json` rather than a + * registry lookup, so a major that was never published makes the install fail loudly — the signal to add the package + * to `nonConsecutiveMajorPackages`. + * + * Notations that resolve to the same exact version (`1.2.3`, `=1.2.3`, `v1.2.3`) collapse to a single key, and `*` + * collapses to the latest major (the same version an open-ended range resolves to), so a version is never installed + * twice under different spellings. A floor that equals the pinned latest also drops the redundant top-major key, + * since the pinned `package.json` proves they resolve to the same version. + * + * @param {string} name The module name, e.g. `mongodb`. + * @param {string[]} versions The declared version entries, e.g. `['>=3.3 <5', '5', '>=6']`. + * @param {Set} [nonConsecutiveMajors] Module names whose majors are not contiguous; injectable for testing. + * @returns {Array<{ versionKey: string, range: string }>} Ordered, de-duplicated entries. `versionKey` is the folder + * suffix; `range` is the declaring entry it came from. + */ +function getVersionList (name, versions, nonConsecutiveMajors = nonConsecutiveMajorPackages) { + /** @type {Map} */ + const entries = new Map() + + const add = (versionKey, range) => { + if (!entries.has(versionKey)) entries.set(versionKey, { versionKey, range }) + } + + for (const range of versions) { + // An empty entry is a setup mistake (a stray comma or an undefined slot); fail loudly rather than skip silently. + if (!range) { + throw new Error(`Empty version entry declared for '${name}'. Each declared version must be a non-empty range.`) + } + + if (range === '*') { + add(latestMajor(name), range) + continue + } + + // Exact-version notations collapse to one key so the same version is never installed twice. + const exact = clean(range) + if (exact) { + add(exact, range) + continue + } + + const floor = coerce(range) + if (!floor) throw new Error(`Invalid version range for '${name}': ${range}`) + + add(floor.version, range) + + const topMajor = highestMajor(name, range, floor.major) + if (nonConsecutiveMajors.has(name)) { + // Only the in-between majors were never published. The floor major and the top major both exist, so add the + // latest of each (skipping the uncertain middle, which is what would make the install fail). + add(String(floor.major), range) + if (topMajor > floor.major) add(String(topMajor), range) + } else { + for (let major = floor.major; major <= topMajor; major++) { + if (intersects(`>=${major}.0.0 <${major + 1}.0.0`, range)) add(String(major), range) + } + } + } + + // The bare-major key for the pinned latest's major resolves to the pin itself, so when a declared floor already pins + // that exact version the major key would install the same thing. Drop it. This is the only such redundancy the + // pinned upper bound lets us prove; for lower majors the newest published version is unknown without the registry. + const pinned = coerce(latests[name]) + if (pinned && entries.has(pinned.version)) entries.delete(String(pinned.major)) + + return [...entries.values()] +} + +/** + * The latest major of `name` as a bare-major version key. `*` resolves here so it de-duplicates against an open-ended + * range whose top resolves to the same newest version. + * + * @param {string} name + * @returns {string} + */ +function latestMajor (name) { + const latest = coerce(latests[name]) + if (!latest) { + throw new Error( + `Latest version for '${name}' needs to be defined in 'packages/dd-trace/test/plugins/versions/package.json'.` + ) + } + return String(latest.major) +} + +/** + * Highest major still spanned by `range`, capped at the pinned latest. Iterates down from the latest so the first + * intersecting major is the top; nothing above the pinned latest is installed. + * + * @param {string} name + * @param {string} range + * @param {number} floorMajor + * @returns {number} + */ +function highestMajor (name, range, floorMajor) { + const latest = coerce(latests[name]) + if (!latest) return floorMajor + for (let major = latest.major; major > floorMajor; major--) { + if (intersects(`>=${major}.0.0 <${major + 1}.0.0`, range)) return major + } + return floorMajor +} + +/** + * Resolve which version keys to install and test for a module, plus which key the unversioned + * `versions/` folder points at. `scripts/install_plugin_modules.js` and `withVersions()` both call this so the + * installed folder set and the tested folder set are derived from one place and cannot drift. + * + * @param {object} options + * @param {string} options.name The module name, e.g. `fastify`. + * @param {string[]} options.declaredVersions The declared version entries to expand. + * @param {boolean} [options.honourEnvRange] Whether `PACKAGE_VERSION_RANGE` applies to this module. False for sibling + * externals that must stay on their declared versions while the matrix shards a different package. + * @param {NodeJS.ProcessEnv} [options.env] Injectable for testing. + * @returns {{ versionList: Array<{ versionKey: string, range: string }>, unversioned: string|undefined }} The ordered, + * `RANGE`-filtered key set, and the key the default `versions/` folder resolves to (the newest in-scope entry, + * or `undefined` when nothing is in scope). + */ +function resolvePluginVersions ({ name, declaredVersions, honourEnvRange = true, env = process.env }) { + const useEnvRange = Boolean(env.PACKAGE_VERSION_RANGE) && honourEnvRange + const versions = useEnvRange ? [env.PACKAGE_VERSION_RANGE] : declaredVersions + + let versionList = getVersionList(name, versions) + if (env.RANGE) { + versionList = versionList.filter(({ versionKey }) => subset(versionKey, env.RANGE)) + } + + // With `PACKAGE_VERSION_RANGE` the shard itself is the target, so the unversioned folder keeps the raw range even + // when `RANGE` narrows the installed keys; otherwise it follows the newest in-scope key. + const unversioned = useEnvRange ? env.PACKAGE_VERSION_RANGE : versionList.at(-1)?.versionKey + + return { versionList, unversioned } +} + module.exports = { - getCappedRange + getCappedRange, + getVersionList, + resolvePluginVersions, }