diff --git a/docs/usage/docker.md b/docs/usage/docker.md index 28838e44b64e83c054a5a69e004839a768075fec..ca3991dd86f7c00c5ecdfdace1b2f6d952c36b61 100644 --- a/docs/usage/docker.md +++ b/docs/usage/docker.md @@ -256,7 +256,7 @@ 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 +##### Using Application Default Credentials / Workload Identity (Self-Hosted only) 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_ diff --git a/docs/usage/java.md b/docs/usage/java.md index 8d1720771f73bbcf151857cec9f5bc4dc513b336..744a109c51fc0de04c1fc4ab9da0bd59d135aebc 100644 --- a/docs/usage/java.md +++ b/docs/usage/java.md @@ -131,3 +131,68 @@ module.exports = { ], }; ``` + +### Google Artifact Registry + +There are multiple ways to configure Renovate to access Artifact Registry. + +#### Using Application Default Credentials / Workload Identity (Self-Hosted only) + +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 Artifact Registry, use the JSON service account with `Basic` authentication, and use the: + +- `_json_key_base64` as username +- full Google Cloud Platform service account JSON as password + +To avoid JSON-in-JSON wrapping, which can cause problems, encode the JSON service account beforehand. + +1. Download your JSON service account and store it on your machine. Make sure that the service account has `read` (and only `read`) permissions to your artifacts +2. Base64 encode the service account credentials by running `cat service-account.json | base64` +3. Add the encoded service account to your configuration file + + 1. If you want to add it to your self-hosted configuration file: + + ```json + { + "hostRules": [ + { + "matchHost": "europe-maven.pkg.dev", + "username": "_json_key_base64", + "password": "<base64 service account>" + } + ] + } + ``` + + 2. If you want to add it to your repository Renovate configuration file, [encrypt](./configuration-options.md#encrypted) it and then add it: + + ```json + { + "hostRules": [ + { + "matchHost": "europe-maven.pkg.dev", + "username": "_json_key_base64", + "encrypted": { + "password": "<encrypted base64 service account>" + } + } + ] + } + ``` + +4. Add the following to the `packageRules` in your repository Renovate configuration file: + + ```json + { + "matchManagers": ["maven", "gradle"], + "registryUrls": [ + "https://europe-maven.pkg.dev/<my-gcp-project>/<my-repository>" + ] + } + ``` diff --git a/lib/modules/datasource/docker/common.ts b/lib/modules/datasource/docker/common.ts index e6d7ba57fd5081e8dfd36e0a5197a179299727f8..e312ab7dab1d811e570f66475405eed048e82b14 100644 --- a/lib/modules/datasource/docker/common.ts +++ b/lib/modules/datasource/docker/common.ts @@ -26,8 +26,9 @@ import { trimTrailingSlash, } from '../../../util/url'; import { api as dockerVersioning } from '../../versioning/docker'; +import { getGoogleAuthToken } from '../util'; import { ecrRegex, getECRAuthToken } from './ecr'; -import { getGoogleAccessToken, googleRegex } from './google'; +import { googleRegex } from './google'; import type { OciHelmConfig } from './schema'; import type { RegistryRepository } from './types'; @@ -110,23 +111,14 @@ export async function getAuthHeaders( { registryHost, dockerRepository }, `Using google auth for Docker registry` ); - try { - const accessToken = await getGoogleAccessToken(); - if (accessToken) { - const auth = Buffer.from( - `${'oauth2accesstoken'}:${accessToken}` - ).toString('base64'); - opts.headers = { authorization: `Basic ${auth}` }; - } - } catch (err) /* istanbul ignore next */ { - if (err.message?.includes('Could not load the default credentials')) { - logger.once.debug( - { registryHost, dockerRepository }, - 'Could not get Google access token, using no auth' - ); - } else { - throw err; - } + const auth = await getGoogleAuthToken(); + if (auth) { + opts.headers = { authorization: `Basic ${auth}` }; + } else { + logger.once.debug( + { registryHost, dockerRepository }, + 'Could not get Google access token, using no auth' + ); } } else if (opts.username && opts.password) { logger.trace( diff --git a/lib/modules/datasource/docker/google.ts b/lib/modules/datasource/docker/google.ts index e9531742ca7c3c225ed029f1eae6dc4e6d40a424..cec035f75865cfa07fa5909dbf816d190a8a2239 100644 --- a/lib/modules/datasource/docker/google.ts +++ b/lib/modules/datasource/docker/google.ts @@ -1,24 +1,5 @@ -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<string | null> { - 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/maven/index.spec.ts b/lib/modules/datasource/maven/index.spec.ts index 0f8cc2fb7573ebac21ad28772491deef9dbf26a4..ab4741d35f3db848033459202a4f3bb71c33fd55 100644 --- a/lib/modules/datasource/maven/index.spec.ts +++ b/lib/modules/datasource/maven/index.spec.ts @@ -1,16 +1,25 @@ +import { GoogleAuth as _googleAuth } from 'google-auth-library'; import { ReleaseResult, getPkgReleases } from '..'; import { Fixtures } from '../../../../test/fixtures'; import * as httpMock from '../../../../test/http-mock'; +import { mocked } from '../../../../test/util'; import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages'; import * as hostRules from '../../../util/host-rules'; import { id as versioning } from '../../versioning/maven'; import { MavenDatasource } from '.'; +const googleAuth = mocked(_googleAuth); +jest.mock('google-auth-library'); + const datasource = MavenDatasource.id; const baseUrl = 'https://repo.maven.apache.org/maven2'; const baseUrlCustom = 'https://custom.registry.renovatebot.com'; +const arRegistry = 'maven.pkg.dev/some-project/some-repository'; +const baseUrlAR = `artifactregistry://${arRegistry}`; +const baseUrlARHttps = `https://${arRegistry}`; + interface SnapshotOpts { version: string; jarStatus?: number; @@ -432,6 +441,116 @@ describe('modules/datasource/maven/index', () => { expect(res).toMatchSnapshot(); }); + it('supports artifactregistry urls with auth', async () => { + const metadataPaths = [ + '/org/example/package/maven-metadata.xml', + '/org/example/package/1.0.3-SNAPSHOT/maven-metadata.xml', + '/org/example/package/1.0.4-SNAPSHOT/maven-metadata.xml', + '/org/example/package/1.0.5-SNAPSHOT/maven-metadata.xml', + ]; + const pomfilePath = '/org/example/package/2.0.0/package-2.0.0.pom'; + hostRules.clear(); + + for (const path of metadataPaths) { + httpMock + .scope(baseUrlARHttps) + .get(path) + .matchHeader( + 'authorization', + 'Basic b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg==' + ) + .reply(200, Fixtures.get('metadata.xml')); + } + + httpMock + .scope(baseUrlARHttps) + .get(pomfilePath) + .matchHeader( + 'authorization', + 'Basic b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg==' + ) + .reply(200, Fixtures.get('pom.xml')); + + googleAuth.mockImplementation( + jest.fn().mockImplementation(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })) + ); + + const res = await get('org.example:package', baseUrlAR); + + expect(res).toEqual({ + display: 'org.example:package', + group: 'org.example', + homepage: 'https://package.example.org/about', + name: 'package', + registryUrl: + 'artifactregistry://maven.pkg.dev/some-project/some-repository', + releases: [ + { version: '0.0.1' }, + { version: '1.0.0' }, + { version: '1.0.1' }, + { version: '1.0.2' }, + { version: '1.0.3-SNAPSHOT' }, + { version: '1.0.4-SNAPSHOT' }, + { version: '1.0.5-SNAPSHOT' }, + { version: '2.0.0' }, + ], + }); + expect(googleAuth).toHaveBeenCalledTimes(5); + }); + + it('supports artifactregistry urls without auth', async () => { + const metadataPaths = [ + '/org/example/package/maven-metadata.xml', + '/org/example/package/1.0.3-SNAPSHOT/maven-metadata.xml', + '/org/example/package/1.0.4-SNAPSHOT/maven-metadata.xml', + '/org/example/package/1.0.5-SNAPSHOT/maven-metadata.xml', + ]; + const pomfilePath = '/org/example/package/2.0.0/package-2.0.0.pom'; + hostRules.clear(); + + for (const path of metadataPaths) { + httpMock + .scope(baseUrlARHttps) + .get(path) + .reply(200, Fixtures.get('metadata.xml')); + } + + httpMock + .scope(baseUrlARHttps) + .get(pomfilePath) + .reply(200, Fixtures.get('pom.xml')); + + googleAuth.mockImplementation( + jest.fn().mockImplementation(() => ({ + getAccessToken: jest.fn().mockResolvedValue(undefined), + })) + ); + + const res = await get('org.example:package', baseUrlAR); + + expect(res).toEqual({ + display: 'org.example:package', + group: 'org.example', + homepage: 'https://package.example.org/about', + name: 'package', + registryUrl: + 'artifactregistry://maven.pkg.dev/some-project/some-repository', + releases: [ + { version: '0.0.1' }, + { version: '1.0.0' }, + { version: '1.0.1' }, + { version: '1.0.2' }, + { version: '1.0.3-SNAPSHOT' }, + { version: '1.0.4-SNAPSHOT' }, + { version: '1.0.5-SNAPSHOT' }, + { version: '2.0.0' }, + ], + }); + expect(googleAuth).toHaveBeenCalledTimes(5); + }); + describe('fetching parent info', () => { const parentPackage = { dep: 'org.example:parent', diff --git a/lib/modules/datasource/maven/util.ts b/lib/modules/datasource/maven/util.ts index a41a5b1e3bd420fb5884d224f08aba624ac243dc..9dee2d60fde052709ed836dbb3439091eb68a827 100644 --- a/lib/modules/datasource/maven/util.ts +++ b/lib/modules/datasource/maven/util.ts @@ -6,13 +6,18 @@ import { HOST_DISABLED } from '../../../constants/error-messages'; import { logger } from '../../../logger'; import { ExternalHostError } from '../../../types/errors/external-host-error'; import type { Http } from '../../../util/http'; -import type { HttpResponse } from '../../../util/http/types'; +import type { + HttpOptions, + HttpRequestOptions, + HttpResponse, +} from '../../../util/http/types'; import { regEx } from '../../../util/regex'; import { getS3Client, parseS3Url } from '../../../util/s3'; import { streamToString } from '../../../util/streams'; import { parseUrl } from '../../../util/url'; import { normalizeDate } from '../metadata'; import type { ReleaseResult } from '../types'; +import { getGoogleAuthToken } from '../util'; import { MAVEN_REPO } from './common'; import type { HttpResourceCheckResult, @@ -63,11 +68,12 @@ function isUnsupportedHostError(err: { name: string }): boolean { export async function downloadHttpProtocol( http: Http, - pkgUrl: URL | string + pkgUrl: URL | string, + opts: HttpOptions & HttpRequestOptions<string> = {} ): Promise<Partial<HttpResponse>> { let raw: HttpResponse; try { - raw = await http.get(pkgUrl.toString()); + raw = await http.get(pkgUrl.toString(), opts); return raw; } catch (err) { const failedUrl = pkgUrl.toString(); @@ -139,6 +145,30 @@ export async function downloadS3Protocol(pkgUrl: URL): Promise<string | null> { return null; } +export async function downloadArtifactRegistryProtocol( + http: Http, + pkgUrl: URL +): Promise<Partial<HttpResponse>> { + const opts: HttpOptions = {}; + const host = pkgUrl.host; + const path = pkgUrl.pathname; + + logger.trace({ host, path }, `Using google auth for Maven repository`); + const auth = await getGoogleAuthToken(); + if (auth) { + opts.headers = { authorization: `Basic ${auth}` }; + } else { + logger.once.debug( + { host, path }, + 'Could not get Google access token, using no auth' + ); + } + + const url = pkgUrl.toString().replace('artifactregistry:', 'https:'); + + return downloadHttpProtocol(http, url, opts); +} + async function checkHttpResource( http: Http, pkgUrl: URL @@ -259,6 +289,13 @@ export async function downloadMavenXml( case 's3:': rawContent = (await downloadS3Protocol(pkgUrl)) ?? undefined; break; + case 'artifactregistry:': + ({ + authorization, + body: rawContent, + statusCode, + } = await downloadArtifactRegistryProtocol(http, pkgUrl)); + break; default: logger.debug(`Unsupported Maven protocol url:${pkgUrl.toString()}`); return {}; diff --git a/lib/modules/datasource/util.ts b/lib/modules/datasource/util.ts index 7293769a2b33e1a1165fe2f49070d8df70dfd4be..10e68673e974e8fb17d9803913bd75e9a211d0ff 100644 --- a/lib/modules/datasource/util.ts +++ b/lib/modules/datasource/util.ts @@ -1,5 +1,8 @@ import is from '@sindresorhus/is'; +import { GoogleAuth } from 'google-auth-library'; +import { logger } from '../../logger'; import type { HttpResponse } from '../../util/http/types'; +import { addSecretForSanitizing } from '../../util/sanitize'; const JFROG_ARTIFACTORY_RES_HEADER = 'x-jfrog-version'; @@ -8,3 +11,28 @@ export function isArtifactoryServer<T = unknown>( ): boolean { return is.string(res?.headers[JFROG_ARTIFACTORY_RES_HEADER]); } + +export async function getGoogleAuthToken(): Promise<string | null> { + try { + 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 Buffer.from(`oauth2accesstoken:${accessToken}`).toString('base64'); + } else { + logger.warn( + 'Could not retrieve access token using google-auth-library getAccessToken' + ); + } + } catch (err) { + if (err.message?.includes('Could not load the default credentials')) { + return null; + } else { + throw err; + } + } + return null; +} diff --git a/lib/modules/datasource/utils.spec.ts b/lib/modules/datasource/utils.spec.ts index 20d8161e84c5310683685ac4ffaf2120050697f7..395dbcc9120b5f6c280e5cfbdec5d489c9df90ea 100644 --- a/lib/modules/datasource/utils.spec.ts +++ b/lib/modules/datasource/utils.spec.ts @@ -1,5 +1,10 @@ +import { GoogleAuth as _googleAuth } from 'google-auth-library'; +import { mocked } from '../../../test/util'; import type { HttpResponse } from '../../util/http/types'; -import { isArtifactoryServer } from './util'; +import { getGoogleAuthToken, isArtifactoryServer } from './util'; + +const googleAuth = mocked(_googleAuth); +jest.mock('google-auth-library'); describe('modules/datasource/utils', () => { it('is artifactory server invalid', () => { @@ -19,4 +24,50 @@ describe('modules/datasource/utils', () => { }; expect(isArtifactoryServer(response)).toBeTrue(); }); + + it('retrieves a Google Access token', async () => { + googleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })) + ); + + const res = await getGoogleAuthToken(); + expect(res).toBe('b2F1dGgyYWNjZXNzdG9rZW46c29tZS10b2tlbg=='); + }); + + it('no Google Access token results in null', async () => { + googleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockReturnValue(''), + })) + ); + + const res = await getGoogleAuthToken(); + expect(res).toBeNull(); + }); + + it('Google Access token error throws an exception', async () => { + const err = 'some-error'; + googleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockRejectedValue(new Error(err)), + })) + ); + + await expect(getGoogleAuthToken()).rejects.toThrow('some-error'); + }); + + it('Google Access token could not load default credentials', async () => { + googleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockRejectedValue({ + message: 'Could not load the default credentials', + }), + })) + ); + + const res = await getGoogleAuthToken(); + expect(res).toBeNull(); + }); });