diff --git a/docs/configuration.md b/docs/configuration.md index 95a5abea958a85430acbc9266d8e19bb94f5cb7f..39f37c96be291e1fa9d8ec1d8b8cdf136d27d320 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -779,10 +779,13 @@ Obviously, you can't set repository or package file location with this method. <td>json</td> <td><pre>{ "enabled": true, - "branchName": "{{branchPrefix}}docker-{{depNameSanitized}}-{{currentTag}}", + "branchName": "{{branchPrefix}}docker-{{depNameSanitized}}-{{newVersionMajor}}.x", "commitMessage": "Update {{depName}}:{{currentTag}} digest", - "prTitle": "Update Dockerfile image {{depName}}@{{currentTag}} digest", + "prTitle": "Update Dockerfile {{depName}} image tag to {{newTag}}", "prBody": "This {{#if isGitHub}}Pull{{else}}Merge{{/if}} Request updates Docker base image `{{depName}}@{{currentTag}}` to the latest digest (`{{newDigest}}`).\n\n{{#if schedule}}\n**Note**: This PR was created on a configured schedule (\"{{schedule}}\"{{#if timezone}} in timezone `{{timezone}}`{{/if}}) and will not receive updates outside those times.\n{{/if}}\n\n{{#if hasErrors}}\n\n---\n\n### Errors\n\nRenovate encountered some errors when processing your repository, so you are being notified here even if they do not directly apply to this PR.\n\n{{#each errors as |error|}}\n- `{{error.depName}}`: {{error.message}}\n{{/each}}\n{{/if}}\n\n{{#if hasWarnings}}\n\n---\n\n### Warnings\n\nPlease make sure the following warnings are safe to ignore:\n\n{{#each warnings as |warning|}}\n- `{{warning.depName}}`: {{warning.message}}\n{{/each}}\n{{/if}}\n\n---\n\nThis {{#if isGitHub}}PR{{else}}MR{{/if}} has been generated by [Renovate Bot](https://renovateapp.com).", + "major": {"enabled": false}, + "minor": {"enabled": false}, + "patch": {"enabled": false}, "pin": { "branchName": "{{branchPrefix}}docker-pin-{{depNameSanitized}}-{{currentTag}}", "prTitle": "Pin Dockerfile {{depName}}@{{currentTag}} image digest", diff --git a/lib/api/docker.js b/lib/api/docker.js index 196d0b9cb4f03a1d3873407b3e4104cf0394c5b8..52d10cbc69573933e9a659661918be02bd0c0e76 100644 --- a/lib/api/docker.js +++ b/lib/api/docker.js @@ -2,6 +2,7 @@ const got = require('got'); module.exports = { getDigest, + getTags, }; async function getDigest(name, tag, logger) { @@ -31,3 +32,28 @@ async function getDigest(name, tag, logger) { return null; } } + +async function getTags(name, logger) { + const repository = name.includes('/') ? name : `library/${name}`; + try { + const authUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`; + logger.debug(`Obtaining docker registry token for ${repository}`); + const { token } = (await got(authUrl, { json: true })).body; + if (!token) { + logger.warn('Failed to obtain docker registry token'); + return null; + } + logger.debug('Got docker registry token'); + const url = `https://index.docker.io/v2/${repository}/tags/list?n=10000`; + const headers = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.docker.distribution.manifest.v2+json', + }; + const res = await got(url, { json: true, headers }); + logger.debug({ tags: res.body.tags }, 'Got docker tags'); + return res.body.tags; + } catch (err) { + logger.warn({ err, name }, 'Error getting docker image tags'); + return null; + } +} diff --git a/lib/config/definitions.js b/lib/config/definitions.js index ed3de2f8ad23b1effc785d687d3de92d43090e72..515ea056fb6601c33ab7235bc6ba8b9a1d170c7c 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -613,6 +613,9 @@ const options = [ commitMessage: template('commitMessage', 'docker'), prTitle: template('prTitle', 'docker'), prBody: template('prBody', 'docker'), + major: { enabled: false }, + minor: { enabled: false }, + patch: { enabled: false }, pin: { branchName: template('branchName', 'docker-pin'), prTitle: template('prTitle', 'docker-pin'), diff --git a/lib/config/templates/docker/branch-name.hbs b/lib/config/templates/docker/branch-name.hbs index 5a353edf92f4bb504e8bf38d03177a975adacf7f..817fd2300e0d65e97f5d2b0567a69adc81098cf7 100644 --- a/lib/config/templates/docker/branch-name.hbs +++ b/lib/config/templates/docker/branch-name.hbs @@ -1 +1 @@ -{{branchPrefix}}docker-{{depNameSanitized}}-{{currentTag}} +{{branchPrefix}}docker-{{depNameSanitized}}-{{newVersionMajor}}.x diff --git a/lib/config/templates/docker/pr-title.hbs b/lib/config/templates/docker/pr-title.hbs index 9924b03b5856a83ea4be6cb342c6c3258cd9e9ec..70b872fedc5b8914af906b871eda5660537fd320 100644 --- a/lib/config/templates/docker/pr-title.hbs +++ b/lib/config/templates/docker/pr-title.hbs @@ -1 +1 @@ -Update Dockerfile image {{depName}}@{{currentTag}} digest +Update Dockerfile {{depName}} image tag to {{newTag}} diff --git a/lib/workers/package/docker.js b/lib/workers/package/docker.js index f148442cb4b3136310c96d5159cdfa598eae55e3..fe021020516e1c2a6d3bd7f3dd2510a8efdf5f82 100644 --- a/lib/workers/package/docker.js +++ b/lib/workers/package/docker.js @@ -1,14 +1,17 @@ +const semver = require('semver'); const dockerApi = require('../../api/docker'); +const versions = require('./versions'); +const compareVersions = require('compare-versions'); module.exports = { renovateDockerImage, }; async function renovateDockerImage(config) { - const { currentTag, logger } = config; + const { currentFrom, currentTag, logger } = config; const upgrades = []; if (config.pinDigests) { - logger.debug('Checking Docker pinDigests'); + logger.debug('Checking docker pinDigests'); const newDigest = await dockerApi.getDigest( config.depName, currentTag, @@ -34,5 +37,87 @@ async function renovateDockerImage(config) { upgrades.push(upgrade); } } + if (currentTag) { + const currentVersion = getVersion(currentTag); + const currentSuffix = getSuffix(currentTag); + if (!versions.isValidVersion(currentVersion)) { + logger.info({ currentFrom }, 'Docker tag is not valid semver - skipping'); + return upgrades; + } + let versionList = []; + const allTags = await dockerApi.getTags(config.depName, config.logger); + if (allTags) { + versionList = allTags + .filter(tag => getSuffix(tag) === currentSuffix) + .map(getVersion) + .filter(versions.isValidVersion) + .filter( + prefix => + prefix.split('.').length === currentVersion.split('.').length + ) + .filter(prefix => compareVersions(prefix, currentVersion) > 0); + } + logger.debug({ versionList }, 'upgrades versionList'); + const versionUpgrades = {}; + for (const version of versionList) { + const paddedVersion = padRange(version); + const major = semver.major(paddedVersion); + if ( + !versionUpgrades[major] || + compareVersions(version, versionUpgrades[major]) > 0 + ) { + versionUpgrades[major] = version; + } + } + logger.debug({ versionUpgrades }, 'Docker versionUpgrades'); + const currentMajor = semver.major(padRange(currentVersion)); + for (const newVersionMajor of Object.keys(versionUpgrades)) { + let newTag = versionUpgrades[newVersionMajor]; + if (currentSuffix) { + newTag += `-${currentSuffix}`; + } + const upgrade = { + newTag, + newVersionMajor, + }; + upgrade.currentVersion = config.currentTag; + upgrade.newVersion = upgrade.newTag; + upgrade.newFrom = `${config.depName}:${upgrade.newTag}`; + if (newVersionMajor > currentMajor) { + upgrade.type = 'major'; + upgrade.isMajor = true; + } else { + upgrade.type = 'minor'; + upgrade.isMinor = true; + } + if (config.currentDigest || config.pinDigests) { + upgrade.newDigest = await dockerApi.getDigest( + config.depName, + upgrade.newTag, + config.logger + ); + upgrade.newFrom += `@${upgrade.newDigest}`; + } + upgrades.push(upgrade); + logger.info( + { currentFrom, newFrom: upgrade.newFrom }, + 'Docker tag version upgrade found' + ); + } + } return upgrades; } + +function getVersion(tag) { + const split = tag.indexOf('-'); + return split > 0 ? tag.substring(0, split) : tag; +} + +function getSuffix(tag) { + const split = tag.indexOf('-'); + return split > 0 ? tag.slice(split + 1) : ''; +} + +function padRange(range) { + return range + '.0'.repeat(3 - range.split('.').length); +} diff --git a/package.json b/package.json index e61637f4680a5fd1abd90237f6dfc292810fbd1c..fed3661ea0c243369a0ee991628cf230cbcc4c28 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "changelog": "1.4.0", "child-process-promise": "2.2.1", "commander": "2.11.0", + "compare-versions": "3.1.0", "conventional-commits-detector": "0.1.1", "convert-hrtime": "2.0.0", "deepcopy": "0.6.3", diff --git a/test/api/docker.spec.js b/test/api/docker.spec.js index 2ee71546629ddb44e08a205e4e9cc9cae1151e06..5ce48e2dc591e386e93887db8aa0178d81098d45 100644 --- a/test/api/docker.spec.js +++ b/test/api/docker.spec.js @@ -36,4 +36,23 @@ describe('api/docker', () => { expect(res).toBe('some-digest'); }); }); + describe('getTags', () => { + it('returns null if no token', async () => { + got.mockReturnValueOnce({ body: {} }); + const res = await docker.getTags('node', logger); + expect(res).toBe(null); + }); + it('returns tags', async () => { + const tags = ['a', 'b']; + got.mockReturnValueOnce({ body: { token: 'some-token ' } }); + got.mockReturnValueOnce({ body: { tags } }); + const res = await docker.getTags('my/node', logger); + expect(res).toBe(tags); + }); + it('returns null on error', async () => { + got.mockReturnValueOnce({}); + const res = await docker.getTags('node', logger); + expect(res).toBe(null); + }); + }); }); diff --git a/test/workers/package/__snapshots__/docker.spec.js.snap b/test/workers/package/__snapshots__/docker.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..6455d888bfda48415056348927bacbbf6d6a8483 --- /dev/null +++ b/test/workers/package/__snapshots__/docker.spec.js.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lib/workers/package/docker renovateDockerImage adds digest 1`] = ` +Array [ + Object { + "isPin": true, + "newDigest": "sha256:one", + "newFrom": "some-dep:1.0.0-something@sha256:one", + "newTag": "1.0.0-something", + "newVersion": "sha256:one", + "type": "pin", + }, + Object { + "currentVersion": "1.0.0-something", + "isMinor": true, + "newDigest": undefined, + "newFrom": "some-dep:1.1.0-something@undefined", + "newTag": "1.1.0-something", + "newVersion": "1.1.0-something", + "newVersionMajor": "1", + "type": "minor", + }, +] +`; + +exports[`lib/workers/package/docker renovateDockerImage returns major and minor upgrades 1`] = ` +Array [ + Object { + "currentVersion": "1.0.0", + "isMinor": true, + "newDigest": "sha256:one", + "newFrom": "some-dep:1.2.0@sha256:one", + "newTag": "1.2.0", + "newVersion": "1.2.0", + "newVersionMajor": "1", + "type": "minor", + }, + Object { + "currentVersion": "1.0.0", + "isMajor": true, + "newDigest": "sha256:two", + "newFrom": "some-dep:2.0.0@sha256:two", + "newTag": "2.0.0", + "newVersion": "2.0.0", + "newVersionMajor": "2", + "type": "major", + }, + Object { + "currentVersion": "1.0.0", + "isMajor": true, + "newDigest": "sha256:three", + "newFrom": "some-dep:3.0.0@sha256:three", + "newTag": "3.0.0", + "newVersion": "3.0.0", + "newVersionMajor": "3", + "type": "major", + }, +] +`; diff --git a/test/workers/package/docker.spec.js b/test/workers/package/docker.spec.js index 0db380b7ed59f1a09ea5e7b7e71e88759d8d1b46..571aea0072445605ded0317fc59114e56e8f3832 100644 --- a/test/workers/package/docker.spec.js +++ b/test/workers/package/docker.spec.js @@ -5,6 +5,7 @@ const logger = require('../../_fixtures/logger'); // jest.mock('../../../lib/api/docker'); dockerApi.getDigest = jest.fn(); +dockerApi.getTags = jest.fn(); describe('lib/workers/package/docker', () => { describe('renovateDockerImage', () => { @@ -38,5 +39,43 @@ describe('lib/workers/package/docker', () => { expect(res).toHaveLength(1); expect(res[0].type).toEqual('pin'); }); + it('returns empty if current tag is not valid version', async () => { + config.currentTag = 'some-text-tag'; + dockerApi.getDigest.mockReturnValueOnce(config.currentDigest); + expect(await docker.renovateDockerImage(config)).toEqual([]); + }); + it('returns major and minor upgrades', async () => { + dockerApi.getDigest.mockReturnValueOnce(config.currentDigest); + dockerApi.getDigest.mockReturnValueOnce('sha256:one'); + dockerApi.getDigest.mockReturnValueOnce('sha256:two'); + dockerApi.getDigest.mockReturnValueOnce('sha256:three'); + dockerApi.getTags.mockReturnValueOnce([ + '1.1.0', + '1.2.0', + '2.0.0', + '3.0.0', + ]); + const res = await docker.renovateDockerImage(config); + expect(res).toMatchSnapshot(); + expect(res).toHaveLength(3); + expect(res[0].type).toEqual('minor'); + expect(res[0].newVersion).toEqual('1.2.0'); + expect(res[1].type).toEqual('major'); + expect(res[2].newVersionMajor).toEqual('3'); + }); + it('adds digest', async () => { + delete config.currentDigest; + config.currentTag = '1.0.0-something'; + dockerApi.getDigest.mockReturnValueOnce('sha256:one'); + dockerApi.getTags.mockReturnValueOnce([ + '1.1.0-something', + '1.2.0-otherthing', + ]); + const res = await docker.renovateDockerImage(config); + expect(res).toMatchSnapshot(); + expect(res).toHaveLength(2); + expect(res[1].type).toEqual('minor'); + expect(res[1].newVersion).toEqual('1.1.0-something'); + }); }); }); diff --git a/test/workers/package/index.spec.js b/test/workers/package/index.spec.js index 8a3b066be08a9d48dd21976238bbebbee6d9c82f..a13ded8982ace053a8b6b06729db54512bc4aebc 100644 --- a/test/workers/package/index.spec.js +++ b/test/workers/package/index.spec.js @@ -40,6 +40,7 @@ describe('lib/workers/package/index', () => { npm.renovateNpmPackage.mockReturnValueOnce([ { type: 'pin' }, { type: 'major' }, + { type: 'minor', enabled: false }, ]); const res = await pkgWorker.renovatePackage(config); expect(res).toHaveLength(1); diff --git a/yarn.lock b/yarn.lock index d5f099a835dafbccc20d90516f0ba1ee1eda63ef..545b5a453ad000cf4c926daceb55a0d0aeb251e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -918,6 +918,10 @@ compare-func@^1.3.1: array-ify "^1.0.0" dot-prop "^3.0.0" +compare-versions@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.1.0.tgz#43310256a5c555aaed4193c04d8f154cf9c6efd5" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"