From 37a0bcfe2ab93d30301c84ebeefd1c182ec8e141 Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen <8818390+kvanzuijlen@users.noreply.github.com> Date: Thu, 21 Sep 2023 17:10:18 +0200 Subject: [PATCH] feat(datasource/docker): Add support for Google Application Default Credentials (#23903) Co-authored-by: Rhys Arkins Co-authored-by: Michael Kriese --- docs/usage/docker.md | 7 + lib/modules/datasource/docker/common.ts | 18 ++ lib/modules/datasource/docker/google.ts | 24 +++ lib/modules/datasource/docker/index.spec.ts | 217 ++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 95 ++++++++- 6 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 lib/modules/datasource/docker/google.ts diff --git a/docs/usage/docker.md b/docs/usage/docker.md index debea30f79fd36..8b509996b6ff1f 100644 --- a/docs/usage/docker.md +++ b/docs/usage/docker.md @@ -256,6 +256,13 @@ Renovate can authenticate with AWS ECR using AWS access key id & secret as the u #### Google Container Registry / Google Artifact Registry +##### Using Application Default Credentials / Workload Identity + +Just configure [ADC](https://cloud.google.com/docs/authentication/provide-credentials-adc) / +[Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) as normal and _don't_ +provide a username, password or token. Renovate will automatically retrieve the credentials using the +google-auth-library. + ##### Using long-lived service account credentials To access the Google Container Registry (deprecated) or the Google Artifact Registry, use the JSON service account with `Basic` authentication, and use the: diff --git a/lib/modules/datasource/docker/common.ts b/lib/modules/datasource/docker/common.ts index a75788cdcae562..a91a712ff611a3 100644 --- a/lib/modules/datasource/docker/common.ts +++ b/lib/modules/datasource/docker/common.ts @@ -27,6 +27,7 @@ import { } from '../../../util/url'; import { api as dockerVersioning } from '../../versioning/docker'; import { ecrRegex, getECRAuthToken } from './ecr'; +import { getGoogleAccessToken, googleRegex } from './google'; import type { OciHelmConfig } from './schema'; import type { RegistryRepository } from './types'; @@ -99,6 +100,23 @@ export async function getAuthHeaders( if (auth) { opts.headers = { authorization: `Basic ${auth}` }; } + } else if ( + googleRegex.test(registryHost) && + typeof opts.username === 'undefined' && + typeof opts.password === 'undefined' && + typeof opts.token === 'undefined' + ) { + logger.trace( + { registryHost, dockerRepository }, + `Using google auth for Docker registry` + ); + const accessToken = await getGoogleAccessToken(); + if (accessToken) { + const auth = Buffer.from( + `${'oauth2accesstoken'}:${accessToken}` + ).toString('base64'); + opts.headers = { authorization: `Basic ${auth}` }; + } } else if (opts.username && opts.password) { logger.trace( { registryHost, dockerRepository }, diff --git a/lib/modules/datasource/docker/google.ts b/lib/modules/datasource/docker/google.ts new file mode 100644 index 00000000000000..e9531742ca7c3c --- /dev/null +++ b/lib/modules/datasource/docker/google.ts @@ -0,0 +1,24 @@ +import { GoogleAuth } from 'google-auth-library'; +import { logger } from '../../../logger'; +import { regEx } from '../../../util/regex'; +import { addSecretForSanitizing } from '../../../util/sanitize'; + +export const googleRegex = regEx( + /(((eu|us|asia)\.)?gcr\.io|[a-z0-9-]+-docker\.pkg\.dev)/ +); + +export async function getGoogleAccessToken(): Promise { + const googleAuth: GoogleAuth = new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/cloud-platform', + }); + const accessToken = await googleAuth.getAccessToken(); + if (accessToken) { + // sanitize token + addSecretForSanitizing(accessToken); + return accessToken; + } + logger.warn( + 'Could not retrieve access token using google-auth-library getAccessToken' + ); + return null; +} diff --git a/lib/modules/datasource/docker/index.spec.ts b/lib/modules/datasource/docker/index.spec.ts index 10d3472a1c981d..bc80ae2838c697 100644 --- a/lib/modules/datasource/docker/index.spec.ts +++ b/lib/modules/datasource/docker/index.spec.ts @@ -4,6 +4,7 @@ import { GetAuthorizationTokenCommandOutput, } from '@aws-sdk/client-ecr'; import { mockClient } from 'aws-sdk-client-mock'; +import * as _googleAuth from 'google-auth-library'; import { mockDeep } from 'jest-mock-extended'; import { getDigest, getPkgReleases } from '..'; import { range } from '../../../../lib/util/range'; @@ -14,14 +15,18 @@ import * as _hostRules from '../../../util/host-rules'; import { DockerDatasource } from '.'; const hostRules = mocked(_hostRules); +const googleAuth = mocked(_googleAuth); jest.mock('../../../util/host-rules', () => mockDeep()); +jest.mock('google-auth-library'); const ecrMock = mockClient(ECRClient); const baseUrl = 'https://index.docker.io/v2'; const authUrl = 'https://auth.docker.io'; const amazonUrl = 'https://123456789.dkr.ecr.us-east-1.amazonaws.com/v2'; +const gcrUrl = 'https://eu.gcr.io/v2'; +const garUrl = 'https://europe-docker.pkg.dev/v2'; const dockerHubUrl = 'https://hub.docker.com/v2/repositories'; function mockEcrAuthResolve( @@ -353,6 +358,218 @@ describe('modules/datasource/docker/index', () => { expect(res).toBeNull(); }); + it('supports Google ADC authentication for gcr', async () => { + httpMock + .scope(gcrUrl) + .get('/') + .reply(401, '', { + 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', + }) + .head('/some-project/some-package/manifests/some-tag') + .matchHeader( + 'authorization', + 'Basic b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg==' + ) + .reply(200, '', { 'docker-content-digest': 'some-digest' }); + + googleAuth.GoogleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })) + ); + + hostRules.find.mockReturnValue({}); + const res = await getDigest( + { + datasource: 'docker', + packageName: 'eu.gcr.io/some-project/some-package', + }, + 'some-tag' + ); + expect(res).toBe('some-digest'); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); + }); + + it('supports Google ADC authentication for gar', async () => { + httpMock + .scope(garUrl) + .get('/') + .reply(401, '', { + 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', + }) + .head('/some-project/some-repo/some-package/manifests/some-tag') + .matchHeader( + 'authorization', + 'Basic b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg==' + ) + .reply(200, '', { 'docker-content-digest': 'some-digest' }); + + googleAuth.GoogleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })) + ); + + hostRules.find.mockReturnValue({}); + const res = await getDigest( + { + datasource: 'docker', + packageName: + 'europe-docker.pkg.dev/some-project/some-repo/some-package', + }, + 'some-tag' + ); + expect(res).toBe('some-digest'); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); + }); + + it('supports basic authentication for gcr', async () => { + httpMock + .scope(gcrUrl) + .get('/') + .reply(401, '', { + 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', + }) + .head('/some-project/some-package/manifests/some-tag') + .matchHeader( + 'authorization', + 'Basic c29tZS11c2VybmFtZTpzb21lLXBhc3N3b3Jk' + ) + .reply(200, '', { 'docker-content-digest': 'some-digest' }); + + googleAuth.GoogleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })) + ); + + const res = await getDigest( + { + datasource: 'docker', + packageName: 'eu.gcr.io/some-project/some-package', + }, + 'some-tag' + ); + expect(res).toBe('some-digest'); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); + }); + + it('supports basic authentication for gar', async () => { + httpMock + .scope(garUrl) + .get('/') + .reply(401, '', { + 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', + }) + .head('/some-project/some-repo/some-package/manifests/some-tag') + .matchHeader( + 'authorization', + 'Basic c29tZS11c2VybmFtZTpzb21lLXBhc3N3b3Jk' + ) + .reply(200, '', { 'docker-content-digest': 'some-digest' }); + + googleAuth.GoogleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })) + ); + + const res = await getDigest( + { + datasource: 'docker', + packageName: + 'europe-docker.pkg.dev/some-project/some-repo/some-package', + }, + 'some-tag' + ); + expect(res).toBe('some-digest'); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); + }); + + it('supports public gcr', async () => { + httpMock + .scope(gcrUrl) + .get('/') + .reply(200) + .head('/some-project/some-package/manifests/some-tag') + .reply(200, '', { 'docker-content-digest': 'some-digest' }); + + hostRules.find.mockReturnValue({}); + const res = await getDigest( + { + datasource: 'docker', + packageName: 'eu.gcr.io/some-project/some-package', + }, + 'some-tag' + ); + expect(res).toBe('some-digest'); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); + }); + + it('supports public gar', async () => { + httpMock + .scope(garUrl) + .get('/') + .reply(200) + .head('/some-project/some-repo/some-package/manifests/some-tag') + .reply(200, '', { 'docker-content-digest': 'some-digest' }); + + hostRules.find.mockReturnValue({}); + const res = await getDigest( + { + datasource: 'docker', + packageName: + 'europe-docker.pkg.dev/some-project/some-repo/some-package', + }, + 'some-tag' + ); + expect(res).toBe('some-digest'); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(0); + }); + + it('continues without token if Google ADC fails for gcr', async () => { + hostRules.find.mockReturnValue({}); + httpMock.scope(gcrUrl).get('/').reply(401, '', { + 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', + }); + googleAuth.GoogleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue(undefined), + })) + ); + const res = await getDigest( + { + datasource: 'docker', + packageName: 'eu.gcr.io/some-project/some-package', + }, + 'some-tag' + ); + expect(res).toBeNull(); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); + }); + + it('continues without token if Google ADC fails for gar', async () => { + hostRules.find.mockReturnValue({}); + httpMock.scope(garUrl).get('/').reply(401, '', { + 'www-authenticate': 'Basic realm="My Private Docker Registry Server"', + }); + googleAuth.GoogleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockRejectedValue('some-error'), + })) + ); + const res = await getDigest( + { + datasource: 'docker', + packageName: + 'europe-docker.pkg.dev/some-project/some-repo/some-package', + }, + 'some-tag' + ); + expect(res).toBeNull(); + expect(googleAuth.GoogleAuth).toHaveBeenCalledTimes(1); + }); + it('continues without token, when no header is present', async () => { httpMock .scope(baseUrl) diff --git a/package.json b/package.json index 0bdcdf2c52570c..24a6d1fc9140ad 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,7 @@ "glob": "10.3.4", "global-agent": "3.0.0", "good-enough-parser": "1.1.23", + "google-auth-library": "9.0.0", "got": "11.8.6", "graph-data-structure": "3.3.0", "handlebars": "4.7.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ade71812f7268..6997447eee2a8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: good-enough-parser: specifier: 1.1.23 version: 1.1.23 + google-auth-library: + specifier: 9.0.0 + version: 9.0.0 got: specifier: 11.8.6 version: 11.8.6 @@ -4434,7 +4437,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /agentkeepalive@4.5.0: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} @@ -4777,9 +4779,17 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: false + /before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + /bignumber.js@9.1.1: + resolution: {integrity: sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==} + dev: false + /bn.js@4.12.0: resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} dev: false @@ -4845,6 +4855,10 @@ packages: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: false + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5559,6 +5573,12 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /editorconfig@2.0.0: resolution: {integrity: sha512-s1NQ63WQ7RNXH6Efb2cwuyRlfpbtdZubvfNe4vCuoyGPewNPY7vah8JUSOFBiJ+jr99Qh8t0xKv0oITc1dclgw==} engines: {node: '>=16'} @@ -6397,6 +6417,30 @@ packages: dev: false optional: true + /gaxios@6.1.0: + resolution: {integrity: sha512-EIHuesZxNyIkUGcTQKQPMICyOpDD/bi+LJIJx+NLsSGmnS7N+xCLRX5bi4e9yAu9AlSZdVq+qlyWWVuTh/483w==} + engines: {node: '>=14'} + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.2 + is-stream: 2.0.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + + /gcp-metadata@6.0.0: + resolution: {integrity: sha512-Ozxyi23/1Ar51wjUT2RDklK+3HxqDr8TLBNK8rBBFQ7T85iIGnXnVusauj06QyqCXRFZig8LZC+TUddWbndlpQ==} + engines: {node: '>=14'} + dependencies: + gaxios: 6.1.0 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /generic-pool@3.9.0: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} @@ -6609,6 +6653,22 @@ packages: moo: 0.5.2 dev: false + /google-auth-library@9.0.0: + resolution: {integrity: sha512-IQGjgQoVUAfOk6khqTVMLvWx26R+yPw9uLyb1MNyMQpdKiKt0Fd9sp4NWoINjyGHR8S3iw12hMTYK7O8J07c6Q==} + engines: {node: '>=14'} + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.1.0 + gcp-metadata: 6.0.0 + gtoken: 7.0.1 + jws: 4.0.0 + lru-cache: 6.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -6655,6 +6715,17 @@ packages: engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} dev: true + /gtoken@7.0.1: + resolution: {integrity: sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==} + engines: {node: '>=14.0.0'} + dependencies: + gaxios: 6.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -6806,7 +6877,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} @@ -7765,6 +7835,12 @@ packages: hasBin: true dev: true + /json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + dependencies: + bignumber.js: 9.1.1 + dev: false + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -7861,6 +7937,21 @@ packages: resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} dev: true + /jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + dev: false + /keyv@4.5.3: resolution: {integrity: sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==} dependencies: