diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index d85407c50b3e35..1450b02d73aec4 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2332,6 +2332,7 @@ Supported lock files: - `Cargo.lock` - `Chart.lock` - `composer.lock` +- `conan.lock` - `flake.lock` - `Gemfile.lock` - `gradle.lockfile` diff --git a/lib/modules/manager/conan/artifacts.spec.ts b/lib/modules/manager/conan/artifacts.spec.ts new file mode 100644 index 00000000000000..e09effd20156f8 --- /dev/null +++ b/lib/modules/manager/conan/artifacts.spec.ts @@ -0,0 +1,346 @@ +import { mockDeep } from 'jest-mock-extended'; +import { join } from 'upath'; +import { envMock, mockExecAll } from '../../../../test/exec-util'; +import { env, fs, mocked } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import type { RepoGlobalConfig } from '../../../config/types'; +import { TEMPORARY_ERROR } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import * as docker from '../../../util/exec/docker'; +import * as _hostRules from '../../../util/host-rules'; +import type { UpdateArtifactsConfig } from '../types'; +import * as conan from '.'; + +jest.mock('../../../util/exec/env'); +jest.mock('../../../util/git'); +jest.mock('../../../util/host-rules', () => mockDeep()); +jest.mock('../../../util/http'); +jest.mock('../../../util/fs'); + +process.env.CONTAINERBASE = 'true'; +const hostRules = mocked(_hostRules); +const config: UpdateArtifactsConfig = {}; + +const adminConfig: RepoGlobalConfig = { + localDir: join('/tmp/github/some/repo'), + cacheDir: join('/tmp/cache'), + containerbaseDir: join('/tmp/cache/containerbase'), + dockerSidecarImage: 'ghcr.io/containerbase/sidecar', +}; + +describe('modules/manager/conan/artifacts', () => { + beforeEach(() => { + env.getChildProcessEnv.mockReturnValue(envMock.basic); + GlobalConfig.set(adminConfig); + docker.resetPrefetchedImages(); + hostRules.getAll.mockReturnValue([]); + }); + + afterEach(() => { + GlobalConfig.reset(); + }); + + it('returns null if updatedDeps are empty and lockFileMaintenance is turned off', async () => { + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps: [], + newPackageFileContent: '', + config, + }), + ).toBeNull(); + expect(logger.trace).toHaveBeenCalledWith( + 'No conan.lock dependencies to update', + ); + }); + + it('returns null if conan.lock was not found', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + + fs.findLocalSiblingOrParent.mockResolvedValueOnce(null); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toBeNull(); + expect(logger.trace).toHaveBeenCalledWith('No conan.lock found'); + }); + + it('returns null if conan.lock read operation failed', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce(null); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith( + 'conan.lock read operation failed', + ); + }); + + it('returns null if read operation failed for new conan.lock', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + const expectedInSnapshot = [ + { + cmd: 'conan lock create conanfile.py', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(null); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toBeNull(); + expect(execSnapshots).toMatchObject(expectedInSnapshot); + expect(logger.debug).toHaveBeenCalledWith( + 'New conan.lock read operation failed', + ); + }); + + it('returns null if original and updated conan.lock files are the same', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + const expectedInSnapshot = [ + { + cmd: 'conan lock create conanfile.py', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toBeNull(); + expect(execSnapshots).toMatchObject(expectedInSnapshot); + expect(logger.trace).toHaveBeenCalledWith('conan.lock is unchanged'); + }); + + it('returns updated conan.lock for conanfile.txt', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + const expectedInSnapshot = [ + { + cmd: 'conan lock create conanfile.txt', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('Updated conan.lock'); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.txt', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toEqual([ + { + file: { + contents: 'Updated conan.lock', + path: 'conan.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject(expectedInSnapshot); + }); + + it('returns updated conan.lock when updateType are not empty', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + const expectedInSnapshot = [ + { + cmd: 'conan lock create conanfile.py', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('Updated conan.lock'); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toEqual([ + { + file: { + contents: 'Updated conan.lock', + path: 'conan.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject(expectedInSnapshot); + }); + + it('returns updated conan.lock when updateType are empty, but updateType is lockFileMaintenance', async () => { + const expectedInSnapshot = [ + { + cmd: 'conan lock create conanfile.py --lockfile=""', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('Updated conan.lock'); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps: [], + newPackageFileContent: '', + config: { ...config, updateType: 'lockFileMaintenance' }, + }), + ).toEqual([ + { + file: { + contents: 'Updated conan.lock', + path: 'conan.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject(expectedInSnapshot); + }); + + it('returns updated conan.lock when updateType are empty, but isLockFileMaintenance is true', async () => { + const expectedInSnapshot = [ + { + cmd: 'conan lock create conanfile.py --lockfile=""', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('Updated conan.lock'); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps: [], + newPackageFileContent: '', + config: { ...config, isLockFileMaintenance: true }, + }), + ).toEqual([ + { + file: { + contents: 'Updated conan.lock', + path: 'conan.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject(expectedInSnapshot); + }); + + it('rethrows temporary error', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + mockExecAll(new Error(TEMPORARY_ERROR)); + + await expect( + conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config: { ...config, updateType: 'lockFileMaintenance' }, + }), + ).rejects.toThrow(TEMPORARY_ERROR); + }); + + it('returns an artifact error when conan.lock update fails', async () => { + const updatedDeps = [ + { + depName: 'dep', + }, + ]; + const errorMessage = 'conan.lock update execution failure message'; + + fs.statLocalFile.mockResolvedValueOnce({ name: 'conan.lock' } as any); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('conan.lock'); + fs.readLocalFile.mockResolvedValueOnce('Original conan.lock'); + mockExecAll(new Error(errorMessage)); + + expect( + await conan.updateArtifacts({ + packageFileName: 'conanfile.py', + updatedDeps, + newPackageFileContent: '', + config: { ...config, updateType: 'lockFileMaintenance' }, + }), + ).toEqual([ + { artifactError: { lockFile: 'conan.lock', stderr: errorMessage } }, + ]); + }); +}); diff --git a/lib/modules/manager/conan/artifacts.ts b/lib/modules/manager/conan/artifacts.ts new file mode 100644 index 00000000000000..ab010be2e5996a --- /dev/null +++ b/lib/modules/manager/conan/artifacts.ts @@ -0,0 +1,105 @@ +import { quote } from 'shlex'; +import { TEMPORARY_ERROR } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { + findLocalSiblingOrParent, + readLocalFile, + writeLocalFile, +} from '../../../util/fs'; +import { getGitEnvironmentVariables } from '../../../util/git/auth'; +import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; + +async function conanLockUpdate( + conanFilePath: string, + isLockFileMaintenance: boolean, +): Promise { + const command = + `conan lock create ${quote(conanFilePath)}` + + (isLockFileMaintenance ? ' --lockfile=""' : ''); + + const execOptions: ExecOptions = { + extraEnv: { ...getGitEnvironmentVariables(['conan']) }, + docker: {}, + }; + + await exec(command, execOptions); +} + +export async function updateArtifacts( + updateArtifact: UpdateArtifact, +): Promise { + const { packageFileName, updatedDeps, newPackageFileContent, config } = + updateArtifact; + + logger.trace(`conan.updateArtifacts(${packageFileName})`); + + const isLockFileMaintenance = + config.updateType === 'lockFileMaintenance' || + config.isLockFileMaintenance === true; + + if (updatedDeps.length === 0 && !isLockFileMaintenance) { + logger.trace('No conan.lock dependencies to update'); + return null; + } + + const lockFileName = await findLocalSiblingOrParent( + packageFileName, + 'conan.lock', + ); + if (!lockFileName) { + logger.trace('No conan.lock found'); + return null; + } + + const existingLockFileContent = await readLocalFile(lockFileName); + if (!existingLockFileContent) { + logger.debug(lockFileName + ' read operation failed'); + return null; + } + + try { + await writeLocalFile(packageFileName, newPackageFileContent); + + logger.trace('Updating ' + lockFileName); + await conanLockUpdate(packageFileName, isLockFileMaintenance); + + const newLockFileContent = await readLocalFile(lockFileName); + if (!newLockFileContent) { + logger.debug('New ' + lockFileName + ' read operation failed'); + return null; + } + + if (existingLockFileContent === newLockFileContent) { + logger.trace(lockFileName + ' is unchanged'); + return null; + } + + logger.trace('Returning updated' + lockFileName); + return [ + { + file: { + type: 'addition', + path: lockFileName, + contents: newLockFileContent, + }, + }, + ]; + } catch (err) { + if (err.message === TEMPORARY_ERROR) { + throw err; + } + + logger.debug({ err }, 'Failed to update ' + lockFileName); + + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } +} diff --git a/lib/modules/manager/conan/index.ts b/lib/modules/manager/conan/index.ts index 42f6a8329aaa36..fd5d424f6a8bb6 100644 --- a/lib/modules/manager/conan/index.ts +++ b/lib/modules/manager/conan/index.ts @@ -1,9 +1,11 @@ export { extractPackageFile } from './extract'; +export { updateArtifacts } from './artifacts'; import type { Category } from '../../../constants'; export { getRangeStrategy } from './range'; import { ConanDatasource } from '../../datasource/conan'; import * as conan from '../../versioning/conan'; +export const supportsLockFileMaintenance = true; export const url = 'https://docs.conan.io'; export const categories: Category[] = ['c']; diff --git a/lib/modules/manager/conan/readme.md b/lib/modules/manager/conan/readme.md index 2027451e032202..9063fe96a93210 100644 --- a/lib/modules/manager/conan/readme.md +++ b/lib/modules/manager/conan/readme.md @@ -3,7 +3,7 @@ The Conan package manager is disabled by default due to slowness in the Conan API. We recommend you only enable it for low volume experimental purposes until [issue #14170](https://github.com/renovatebot/renovate/issues/14170) is resolved. -Renovate can upgrade dependencies in `conanfile.txt` or `conanfile.py` files. +Renovate can upgrade dependencies in `conanfile.txt` or `conanfile.py` files and also updates `conan.lock` files too if found. How it works: @@ -20,6 +20,7 @@ How it works: - and the `python_requires`, `requires` and `build_requires` variables in the `conanfile.py` format 1. Renovate resolves the dependency's version using the Conan v2 API 1. If Renovate finds an update, Renovate will update `conanfile.txt` or `conanfile.py` +1. Renovate also updates `conan.lock` file if exists Enabling Conan updating