diff --git a/lib/modules/manager/helmfile/artifacts.ts b/lib/modules/manager/helmfile/artifacts.ts index 87c0b6c229b103efe026ebcc1d71491d8d18dbc3..447fa645fe527ceae90e812dec905b6b785d1f7c 100644 --- a/lib/modules/manager/helmfile/artifacts.ts +++ b/lib/modules/manager/helmfile/artifacts.ts @@ -75,7 +75,7 @@ export async function updateArtifacts({ const doc = parseDoc(newPackageFileContent); for (const value of coerceArray(doc.repositories).filter(isOCIRegistry)) { - const loginCmd = generateRegistryLoginCmd( + const loginCmd = await generateRegistryLoginCmd( value.name, `https://${value.url}`, // this extracts the hostname from url like format ghcr.ip/helm-charts diff --git a/lib/modules/manager/helmfile/utils.ts b/lib/modules/manager/helmfile/utils.ts index b8acb7cc114a90c7d6e9d871185e66abee5b7a71..76234764b876f85831f6ef467bb1060b9967265f 100644 --- a/lib/modules/manager/helmfile/utils.ts +++ b/lib/modules/manager/helmfile/utils.ts @@ -45,11 +45,11 @@ export function isOCIRegistry(repository: Repository): boolean { return repository.oci === true; } -export function generateRegistryLoginCmd( +export async function generateRegistryLoginCmd( repositoryName: string, repositoryBaseURL: string, repositoryHost: string -): string | null { +): Promise<string | null> { const repositoryRule: RepositoryRule = { name: repositoryName, repository: repositoryHost, @@ -59,5 +59,5 @@ export function generateRegistryLoginCmd( }), }; - return generateLoginCmd(repositoryRule, 'helm registry login'); + return await generateLoginCmd(repositoryRule, 'helm registry login'); } diff --git a/lib/modules/manager/helmv3/__fixtures__/ChartECR.yaml b/lib/modules/manager/helmv3/__fixtures__/ChartECR.yaml new file mode 100644 index 0000000000000000000000000000000000000000..af9e279589476a6526b1fa140a9dde9cadff353c --- /dev/null +++ b/lib/modules/manager/helmv3/__fixtures__/ChartECR.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: app +version: 0.1.0 +dependencies: + - name: some-ecr-chart + version: 1.2.3 + repository: oci://123456789.dkr.ecr.us-east-1.amazonaws.com diff --git a/lib/modules/manager/helmv3/__fixtures__/oci_1_ecr.lock b/lib/modules/manager/helmv3/__fixtures__/oci_1_ecr.lock new file mode 100644 index 0000000000000000000000000000000000000000..16f834c7eee325cc397f2dd4022e7a6e1d08daf4 --- /dev/null +++ b/lib/modules/manager/helmv3/__fixtures__/oci_1_ecr.lock @@ -0,0 +1,6 @@ +dependencies: +- name: some-ecr-chart + repository: oci://123456789.dkr.ecr.us-east-1.amazonaws.com + version: 1.2.3 +digest: sha256:886f204516ea48785fe615d22071d742f7fb0d6519ed3cd274f4ec0978d8b82b +generated: "2022-01-20T17:48:47.610371241+01:00" diff --git a/lib/modules/manager/helmv3/__fixtures__/oci_2_ecr.lock b/lib/modules/manager/helmv3/__fixtures__/oci_2_ecr.lock new file mode 100644 index 0000000000000000000000000000000000000000..623c5f993ec016874cd8396d8fb0a7ab6beb00d6 --- /dev/null +++ b/lib/modules/manager/helmv3/__fixtures__/oci_2_ecr.lock @@ -0,0 +1,6 @@ +dependencies: +- name: some-ecr-chart + repository: oci://123456789.dkr.ecr.us-east-1.amazonaws.com + version: 1.3.4 +digest: sha256:886f204516ea48785fe615d22071d742f7fb0d6519ed3cd274f4ec0978d8b82b +generated: "2022-01-20T17:48:47.610371241+01:00" diff --git a/lib/modules/manager/helmv3/artifacts.spec.ts b/lib/modules/manager/helmv3/artifacts.spec.ts index a0166b1a2ba6127b3066e2347325570ce8996a52..3029b358f35c65411944f8f13f9348f2f1142904 100644 --- a/lib/modules/manager/helmv3/artifacts.spec.ts +++ b/lib/modules/manager/helmv3/artifacts.spec.ts @@ -1,3 +1,9 @@ +import { + ECRClient, + GetAuthorizationTokenCommand, + GetAuthorizationTokenCommandOutput, +} from '@aws-sdk/client-ecr'; +import { mockClient } from 'aws-sdk-client-mock'; import { mockDeep } from 'jest-mock-extended'; import { join } from 'upath'; import { envMock, mockExecAll } from '../../../../test/exec-util'; @@ -8,6 +14,7 @@ import type { RepoGlobalConfig } from '../../../config/types'; import * as docker from '../../../util/exec/docker'; import type { StatusResult } from '../../../util/git/types'; import * as hostRules from '../../../util/host-rules'; +import { toBase64 } from '../../../util/string'; import * as _datasource from '../../datasource'; import type { UpdateArtifactsConfig } from '../types'; import * as helmv3 from '.'; @@ -17,7 +24,6 @@ jest.mock('../../../util/exec/env'); jest.mock('../../../util/http'); jest.mock('../../../util/fs'); jest.mock('../../../util/git'); - const datasource = mocked(_datasource); const adminConfig: RepoGlobalConfig = { @@ -35,12 +41,29 @@ const ociLockFile1Alias = Fixtures.get('oci_1_alias.lock'); const ociLockFile2Alias = Fixtures.get('oci_2_alias.lock'); const chartFileAlias = Fixtures.get('ChartAlias.yaml'); +const ociLockFile1ECR = Fixtures.get('oci_1_ecr.lock'); +const ociLockFile2ECR = Fixtures.get('oci_2_ecr.lock'); +const chartFileECR = Fixtures.get('ChartECR.yaml'); + +const ecrMock = mockClient(ECRClient); + +function mockEcrAuthResolve( + res: Partial<GetAuthorizationTokenCommandOutput> = {} +) { + ecrMock.on(GetAuthorizationTokenCommand).resolvesOnce(res); +} + +function mockEcrAuthReject(msg: string) { + ecrMock.on(GetAuthorizationTokenCommand).rejectsOnce(new Error(msg)); +} + describe('modules/manager/helmv3/artifacts', () => { beforeEach(() => { env.getChildProcessEnv.mockReturnValue(envMock.basic); GlobalConfig.set(adminConfig); docker.resetPrefetchedImages(); hostRules.clear(); + ecrMock.reset(); }); afterEach(() => { @@ -723,6 +746,240 @@ describe('modules/manager/helmv3/artifacts', () => { expect(execSnapshots).toMatchSnapshot(); }); + it('supports ECR authentication', async () => { + mockEcrAuthResolve({ + authorizationData: [ + { authorizationToken: toBase64('token-username:token-password') }, + ], + }); + + hostRules.add({ + username: 'some-username', + password: 'some-password', + token: 'some-session-token', + hostType: 'docker', + matchHost: '123456789.dkr.ecr.us-east-1.amazonaws.com', + }); + + fs.getSiblingFileName.mockReturnValueOnce('Chart.lock'); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile1ECR as never); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile2ECR as never); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache' + ); + fs.getParentDir.mockReturnValue(''); + + expect( + await helmv3.updateArtifacts({ + packageFileName: 'Chart.yaml', + updatedDeps: [], + newPackageFileContent: chartFileECR, + config: { + ...config, + updateType: 'lockFileMaintenance', + registryAliases: {}, + }, + }) + ).toMatchObject([ + { + file: { + type: 'addition', + path: 'Chart.lock', + contents: ociLockFile2ECR, + }, + }, + ]); + + const ecr = ecrMock.call(0).thisValue as ECRClient; + expect(await ecr.config.region()).toBe('us-east-1'); + expect(await ecr.config.credentials()).toEqual({ + accessKeyId: 'some-username', + secretAccessKey: 'some-password', + sessionToken: 'some-session-token', + }); + + expect(execSnapshots).toMatchObject([ + { + cmd: 'helm registry login --username token-username --password token-password 123456789.dkr.ecr.us-east-1.amazonaws.com', + }, + { + cmd: "helm dependency update ''", + }, + ]); + }); + + it("does not use ECR authentication when the host rule's username is AWS", async () => { + mockEcrAuthResolve({ + authorizationData: [ + { authorizationToken: toBase64('token-username:token-password') }, + ], + }); + + hostRules.add({ + username: 'AWS', + password: 'some-password', + token: 'some-session-token', + hostType: 'docker', + matchHost: '123456789.dkr.ecr.us-east-1.amazonaws.com', + }); + + fs.getSiblingFileName.mockReturnValueOnce('Chart.lock'); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile1ECR as never); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile2ECR as never); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache' + ); + fs.getParentDir.mockReturnValue(''); + + expect( + await helmv3.updateArtifacts({ + packageFileName: 'Chart.yaml', + updatedDeps: [], + newPackageFileContent: chartFileECR, + config: { + ...config, + updateType: 'lockFileMaintenance', + registryAliases: {}, + }, + }) + ).toMatchObject([ + { + file: { + type: 'addition', + path: 'Chart.lock', + contents: ociLockFile2ECR, + }, + }, + ]); + + expect(ecrMock.calls).toHaveLength(0); + + expect(execSnapshots).toMatchObject([ + { + cmd: 'helm registry login --username AWS --password some-password 123456789.dkr.ecr.us-east-1.amazonaws.com', + }, + { + cmd: "helm dependency update ''", + }, + ]); + }); + + it('continues without auth if the ECR token is invalid', async () => { + mockEcrAuthResolve({ + authorizationData: [{ authorizationToken: ':' }], + }); + + hostRules.add({ + username: 'some-username', + password: 'some-password', + token: 'some-session-token', + hostType: 'docker', + matchHost: '123456789.dkr.ecr.us-east-1.amazonaws.com', + }); + + fs.getSiblingFileName.mockReturnValueOnce('Chart.lock'); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile1ECR as never); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile2ECR as never); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache' + ); + fs.getParentDir.mockReturnValue(''); + + expect( + await helmv3.updateArtifacts({ + packageFileName: 'Chart.yaml', + updatedDeps: [], + newPackageFileContent: chartFileECR, + config: { + ...config, + updateType: 'lockFileMaintenance', + registryAliases: {}, + }, + }) + ).toMatchObject([ + { + file: { + type: 'addition', + path: 'Chart.lock', + contents: ociLockFile2ECR, + }, + }, + ]); + + const ecr = ecrMock.call(0).thisValue as ECRClient; + expect(await ecr.config.region()).toBe('us-east-1'); + expect(await ecr.config.credentials()).toEqual({ + accessKeyId: 'some-username', + secretAccessKey: 'some-password', + sessionToken: 'some-session-token', + }); + + expect(execSnapshots).toMatchObject([ + { + cmd: "helm dependency update ''", + }, + ]); + }); + + it('continues without auth if ECR authentication fails', async () => { + mockEcrAuthReject('some error'); + + hostRules.add({ + username: 'some-username', + password: 'some-password', + token: 'some-session-token', + hostType: 'docker', + matchHost: '123456789.dkr.ecr.us-east-1.amazonaws.com', + }); + + fs.getSiblingFileName.mockReturnValueOnce('Chart.lock'); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile1ECR as never); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(ociLockFile2ECR as never); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache' + ); + fs.getParentDir.mockReturnValue(''); + + expect( + await helmv3.updateArtifacts({ + packageFileName: 'Chart.yaml', + updatedDeps: [], + newPackageFileContent: chartFileECR, + config: { + ...config, + updateType: 'lockFileMaintenance', + registryAliases: {}, + }, + }) + ).toMatchObject([ + { + file: { + type: 'addition', + path: 'Chart.lock', + contents: ociLockFile2ECR, + }, + }, + ]); + + const ecr = ecrMock.call(0).thisValue as ECRClient; + expect(await ecr.config.region()).toBe('us-east-1'); + expect(await ecr.config.credentials()).toEqual({ + accessKeyId: 'some-username', + secretAccessKey: 'some-password', + sessionToken: 'some-session-token', + }); + + expect(execSnapshots).toMatchObject([ + { + cmd: "helm dependency update ''", + }, + ]); + }); + it('alias name is picked, when repository is as alias and dependency defined', async () => { hostRules.add({ username: 'basicUser', diff --git a/lib/modules/manager/helmv3/artifacts.ts b/lib/modules/manager/helmv3/artifacts.ts index a5d1538a832b489719b711e8f239c5383d34261d..16677538e5a5a552472b26aaca5cf1adf31fcb1d 100644 --- a/lib/modules/manager/helmv3/artifacts.ts +++ b/lib/modules/manager/helmv3/artifacts.ts @@ -1,5 +1,6 @@ import is from '@sindresorhus/is'; import yaml from 'js-yaml'; +import pMap from 'p-map'; import { quote } from 'shlex'; import { TEMPORARY_ERROR } from '../../../constants/error-messages'; import { logger } from '../../../logger'; @@ -46,8 +47,8 @@ async function helmCommands( }); // if credentials for the registry have been found, log into it - registries.forEach((value) => { - const loginCmd = generateLoginCmd(value, 'helm registry login'); + await pMap(registries, async (value) => { + const loginCmd = await generateLoginCmd(value, 'helm registry login'); if (loginCmd) { cmd.push(loginCmd); } diff --git a/lib/modules/manager/helmv3/common.ts b/lib/modules/manager/helmv3/common.ts index d2c81669b4cb469ce13ca2ea7f50c6f586b0e0ce..50c61003216c0f81f1249a5fcc406bcddf2bdc59 100644 --- a/lib/modules/manager/helmv3/common.ts +++ b/lib/modules/manager/helmv3/common.ts @@ -1,19 +1,43 @@ import { quote } from 'shlex'; import upath from 'upath'; +import { logger } from '../../../logger'; +import { coerceArray } from '../../../util/array'; import type { ExtraEnv } from '../../../util/exec/types'; import { privateCacheDir } from '../../../util/fs'; +import { addSecretForSanitizing } from '../../../util/sanitize'; +import { fromBase64 } from '../../../util/string'; +import { ecrRegex, getECRAuthToken } from '../../datasource/docker/ecr'; import type { RepositoryRule } from './types'; -export function generateLoginCmd( +export async function generateLoginCmd( repositoryRule: RepositoryRule, loginCMD: string -): string | null { - const { username, password } = repositoryRule.hostRule; +): Promise<string | null> { + const { hostRule, repository } = repositoryRule; + const { username, password } = hostRule; + if (username !== 'AWS' && ecrRegex.test(repository)) { + logger.trace({ repository }, `Using ecr auth for Helm registry`); + const [, region] = coerceArray(ecrRegex.exec(repository)); + const auth = await getECRAuthToken(region, hostRule); + if (!auth) { + return null; + } + const [username, password] = fromBase64(auth).split(':'); + if (!username || !password) { + return null; + } + addSecretForSanitizing(username); + addSecretForSanitizing(password); + return `${loginCMD} --username ${quote(username)} --password ${quote( + password + )} ${repository}`; + } if (username && password) { + logger.trace({ repository }, `Using basic auth for Helm registry`); return `${loginCMD} --username ${quote(username)} --password ${quote( password - )} ${repositoryRule.repository}`; + )} ${repository}`; } return null; }