diff --git a/lib/modules/manager/poetry/__fixtures__/pyproject.10.toml b/lib/modules/manager/poetry/__fixtures__/pyproject.10.toml index 65c5e06ba8d947..44e0eb41e200a9 100644 --- a/lib/modules/manager/poetry/__fixtures__/pyproject.10.toml +++ b/lib/modules/manager/poetry/__fixtures__/pyproject.10.toml @@ -16,6 +16,10 @@ url = "last.url" [[tool.poetry.source]] name = "five" +[[tool.poetry.source]] +name = "invalid-url" +url = "invalid-url" + [build-system] requires = ["poetry_core>=1.0", "wheel"] build-backend = "poetry.masonry.api" diff --git a/lib/modules/manager/poetry/artifacts.spec.ts b/lib/modules/manager/poetry/artifacts.spec.ts index 0ea60e8bb8a633..f7db72a8c48838 100644 --- a/lib/modules/manager/poetry/artifacts.spec.ts +++ b/lib/modules/manager/poetry/artifacts.spec.ts @@ -1,4 +1,5 @@ import { codeBlock } from 'common-tags'; +import { GoogleAuth as _googleAuth } from 'google-auth-library'; import { mockDeep } from 'jest-mock-extended'; import { join } from 'upath'; import { envMock, mockExecAll } from '../../../../test/exec-util'; @@ -15,16 +16,26 @@ import { updateArtifacts } from '.'; const pyproject1toml = Fixtures.get('pyproject.1.toml'); const pyproject10toml = Fixtures.get('pyproject.10.toml'); +const pyproject13toml = `[[tool.poetry.source]] +name = "some-gar-repo" +url = "https://someregion-python.pkg.dev/some-project/some-repo/simple/" + +[build-system] +requires = ["poetry_core>=1.0", "wheel"] +build-backend = "poetry.masonry.api" +`; jest.mock('../../../util/exec/env'); jest.mock('../../../util/fs'); jest.mock('../../datasource', () => mockDeep()); jest.mock('../../../util/host-rules', () => mockDeep()); +jest.mock('google-auth-library'); process.env.CONTAINERBASE = 'true'; const datasource = mocked(_datasource); const hostRules = mocked(_hostRules); +const googleAuth = mocked(_googleAuth); const adminConfig: RepoGlobalConfig = { localDir: join('/tmp/github/some/repo'), @@ -198,7 +209,99 @@ describe('modules/manager/poetry/artifacts', () => { }, }, ]); - expect(hostRules.find.mock.calls).toHaveLength(5); + expect(hostRules.find.mock.calls).toHaveLength(7); + expect(execSnapshots).toMatchObject([ + { + cmd: 'poetry update --lock --no-interaction dep1', + options: { + env: { + POETRY_HTTP_BASIC_ONE_PASSWORD: 'passwordOne', + POETRY_HTTP_BASIC_ONE_USERNAME: 'usernameOne', + POETRY_HTTP_BASIC_TWO_USERNAME: 'usernameTwo', + POETRY_HTTP_BASIC_FOUR_OH_FOUR_PASSWORD: 'passwordFour', + }, + }, + }, + ]); + }); + + it('passes Google Artifact Registry credentials environment vars', async () => { + // poetry.lock + fs.getSiblingFileName.mockReturnValueOnce('poetry.lock'); + fs.readLocalFile.mockResolvedValueOnce(null); + // pyproject.lock + fs.getSiblingFileName.mockReturnValueOnce('pyproject.lock'); + fs.readLocalFile.mockResolvedValueOnce('[metadata]\n'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('New poetry.lock'); + googleAuth.mockImplementationOnce( + jest.fn().mockImplementationOnce(() => ({ + getAccessToken: jest.fn().mockResolvedValue('some-token'), + })), + ); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await updateArtifacts({ + packageFileName: 'pyproject.toml', + updatedDeps, + newPackageFileContent: pyproject13toml, + config, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'pyproject.lock', + contents: 'New poetry.lock', + }, + }, + ]); + expect(hostRules.find.mock.calls).toHaveLength(3); + expect(execSnapshots).toMatchObject([ + { + cmd: 'poetry update --lock --no-interaction dep1', + options: { + env: { + POETRY_HTTP_BASIC_SOME_GAR_REPO_USERNAME: 'oauth2accesstoken', + POETRY_HTTP_BASIC_SOME_GAR_REPO_PASSWORD: 'some-token', + }, + }, + }, + ]); + }); + + it('continues if Google auth is not configured', async () => { + // poetry.lock + fs.getSiblingFileName.mockReturnValueOnce('poetry.lock'); + fs.readLocalFile.mockResolvedValueOnce(null); + // pyproject.lock + fs.getSiblingFileName.mockReturnValueOnce('pyproject.lock'); + fs.readLocalFile.mockResolvedValueOnce('[metadata]\n'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('New poetry.lock'); + googleAuth.mockImplementation( + jest.fn().mockImplementation(() => ({ + getAccessToken: jest.fn().mockResolvedValue(undefined), + })), + ); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await updateArtifacts({ + packageFileName: 'pyproject.toml', + updatedDeps, + newPackageFileContent: pyproject13toml, + config, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'pyproject.lock', + contents: 'New poetry.lock', + }, + }, + ]); + expect(hostRules.find.mock.calls).toHaveLength(3); expect(execSnapshots).toMatchObject([ { cmd: 'poetry update --lock --no-interaction dep1' }, ]); diff --git a/lib/modules/manager/poetry/artifacts.ts b/lib/modules/manager/poetry/artifacts.ts index d5582f619c5910..ec77248288828c 100644 --- a/lib/modules/manager/poetry/artifacts.ts +++ b/lib/modules/manager/poetry/artifacts.ts @@ -17,7 +17,9 @@ import { find } from '../../../util/host-rules'; import { regEx } from '../../../util/regex'; import { Result } from '../../../util/result'; import { parse as parseToml } from '../../../util/toml'; +import { parseUrl } from '../../../util/url'; import { PypiDatasource } from '../../datasource/pypi'; +import { getGoogleAuthTokenRaw } from '../../datasource/util'; import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; import { Lockfile, PoetrySchemaToml } from './schema'; import type { PoetryFile, PoetrySource } from './types'; @@ -101,7 +103,7 @@ function getPoetrySources(content: string, fileName: string): PoetrySource[] { return []; } if (!pyprojectFile.tool?.poetry) { - logger.debug(`{$fileName} contains no poetry section`); + logger.debug(`${fileName} contains no poetry section`); return []; } @@ -115,20 +117,42 @@ function getPoetrySources(content: string, fileName: string): PoetrySource[] { return sourceArray; } -function getMatchingHostRule(url: string | undefined): HostRule { +async function getMatchingHostRule(url: string | undefined): Promise { const scopedMatch = find({ hostType: PypiDatasource.id, url }); - return is.nonEmptyObject(scopedMatch) ? scopedMatch : find({ url }); + const hostRule = is.nonEmptyObject(scopedMatch) ? scopedMatch : find({ url }); + if (hostRule) { + return hostRule; + } + + const parsedUrl = parseUrl(url); + if (!parsedUrl) { + logger.once.debug(`Failed to parse URL ${url}`); + return {}; + } + + if (parsedUrl.hostname.endsWith('.pkg.dev')) { + const accessToken = await getGoogleAuthTokenRaw(); + if (accessToken) { + return { + username: 'oauth2accesstoken', + password: accessToken, + }; + } + logger.once.debug(`Could not get Google access token (url=${url})`); + } + + return {}; } -function getSourceCredentialVars( +async function getSourceCredentialVars( pyprojectContent: string, packageFileName: string, -): NodeJS.ProcessEnv { +): Promise { const poetrySources = getPoetrySources(pyprojectContent, packageFileName); const envVars: NodeJS.ProcessEnv = {}; for (const source of poetrySources) { - const matchingHostRule = getMatchingHostRule(source.url); + const matchingHostRule = await getMatchingHostRule(source.url); const formattedSourceName = source.name .replace(regEx(/(\.|-)+/g), '_') .toUpperCase(); @@ -192,7 +216,10 @@ export async function updateArtifacts({ config.constraints?.poetry ?? getPoetryRequirement(newPackageFileContent, existingLockFileContent); const extraEnv = { - ...getSourceCredentialVars(newPackageFileContent, packageFileName), + ...(await getSourceCredentialVars( + newPackageFileContent, + packageFileName, + )), ...getGitEnvironmentVariables(['poetry']), PIP_CACHE_DIR: await ensureCacheDir('pip'), };