diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 06939c884c2ca8..e0d77944dfdb79 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -47,6 +47,7 @@ import * as kubernetes from './kubernetes'; import * as kustomize from './kustomize'; import * as leiningen from './leiningen'; import * as maven from './maven'; +import * as mavenWrapper from './maven-wrapper'; import * as meteor from './meteor'; import * as mint from './mint'; import * as mix from './mix'; @@ -133,6 +134,7 @@ api.set('kubernetes', kubernetes); api.set('kustomize', kustomize); api.set('leiningen', leiningen); api.set('maven', maven); +api.set('maven-wrapper', mavenWrapper); api.set('meteor', meteor); api.set('mint', mint); api.set('mix', mix); diff --git a/lib/modules/manager/maven-wrapper/artifacts.spec.ts b/lib/modules/manager/maven-wrapper/artifacts.spec.ts new file mode 100644 index 00000000000000..cb340197f27b1f --- /dev/null +++ b/lib/modules/manager/maven-wrapper/artifacts.spec.ts @@ -0,0 +1,311 @@ +import type { Stats } from 'fs'; +import os from 'os'; +import type { StatusResult } from 'simple-git'; +import { join } from 'upath'; +import { envMock, mockExecAll } from '../../../../test/exec-util'; +import { env, fs, git, mockedFunction, partial } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import { resetPrefetchedImages } from '../../../util/exec/docker'; +import { getPkgReleases } from '../../datasource'; +import { updateArtifacts } from '.'; + +jest.mock('../../../util/fs'); +jest.mock('../../../util/git'); +jest.spyOn(os, 'platform').mockImplementation(() => 'darwin'); +jest.mock('../../../util/exec/env'); +jest.mock('../../datasource'); +process.env.CONTAINERBASE = 'true'; + +function mockMavenFileChangedInGit(fileName = 'maven-wrapper.properties') { + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [`maven.mvn/wrapper/${fileName}`], + }) + ); +} + +describe('modules/manager/maven-wrapper/artifacts', () => { + beforeEach(() => { + GlobalConfig.set({ localDir: join('/tmp/github/some/repo') }); + jest.resetAllMocks(); + fs.statLocalFile.mockResolvedValue( + partial({ + isFile: () => true, + mode: 0o555, + }) + ); + + resetPrefetchedImages(); + + env.getChildProcessEnv.mockReturnValue({ + ...envMock.basic, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + }); + mockedFunction(getPkgReleases).mockResolvedValueOnce({ + releases: [ + { version: '8.0.1' }, + { version: '11.0.1' }, + { version: '16.0.1' }, + { version: '17.0.0' }, + ], + }); + }); + + afterEach(() => { + GlobalConfig.reset(); + }); + + it('Should not update if there is no dep with maven:wrapper', async () => { + const execSnapshots = mockExecAll({ stdout: '', stderr: '' }); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven-wrapper', + newPackageFileContent: '', + updatedDeps: [{ depName: 'not-mavenwrapper' }], + config: {}, + }); + expect(updatedDeps).toBeNull(); + expect(execSnapshots).toBeEmptyArray(); + }); + + it('Docker should use java 8 if version is lower then 2.0.0', async () => { + mockMavenFileChangedInGit(); + const execSnapshots = mockExecAll(); + GlobalConfig.set({ localDir: './', binarySource: 'docker' }); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { + currentValue: '2.0.0', + newValue: '3.3.1', + constraints: undefined, + }, + }); + + const expected = [ + { + file: { + contents: undefined, + path: 'maven.mvn/wrapper/maven-wrapper.properties', + type: 'addition', + }, + }, + ]; + + expect(execSnapshots[2].cmd).toContain('java 8.0.1'); + expect(updatedDeps).toEqual(expected); + }); + + it('Should update when it is maven wrapper', async () => { + mockMavenFileChangedInGit(); + const execSnapshots = mockExecAll({ stdout: '', stderr: '' }); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { currentValue: '3.3.1', newValue: '3.3.1' }, + }); + + const expected = [ + { + file: { + contents: undefined, + path: 'maven.mvn/wrapper/maven-wrapper.properties', + type: 'addition', + }, + }, + ]; + expect(updatedDeps).toEqual(expected); + expect(execSnapshots).toEqual([ + { + cmd: './mvnw wrapper:wrapper', + options: { + cwd: '/tmp/github', + encoding: 'utf-8', + env: { + HOME: '/home/user', + HTTPS_PROXY: 'https://example.com', + HTTP_PROXY: 'http://example.com', + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + NO_PROXY: 'localhost', + PATH: '/tmp/path', + }, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + + it('Should not update deps when maven-wrapper.properties is not in git change', async () => { + mockMavenFileChangedInGit('not-maven-wrapper.properties'); + const execSnapshots = mockExecAll({ stdout: '', stderr: '' }); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { newValue: '3.3.1' }, + }); + expect(updatedDeps).toEqual([]); + expect(execSnapshots).toEqual([ + { + cmd: './mvnw wrapper:wrapper', + options: { + cwd: '/tmp/github', + encoding: 'utf-8', + env: { + HOME: '/home/user', + HTTPS_PROXY: 'https://example.com', + HTTP_PROXY: 'http://example.com', + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + NO_PROXY: 'localhost', + PATH: '/tmp/path', + }, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + + it('updates with docker', async () => { + mockMavenFileChangedInGit(); + GlobalConfig.set({ localDir: './', binarySource: 'docker' }); + const execSnapshots = mockExecAll({ stdout: '', stderr: '' }); + const result = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { currentValue: '3.3.0', newValue: '3.3.1' }, + }); + expect(result).toEqual([ + { + file: { + contents: undefined, + path: 'maven.mvn/wrapper/maven-wrapper.properties', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'docker pull renovate/sidecar', + options: { encoding: 'utf-8' }, + }, + { cmd: 'docker ps --filter name=renovate_sidecar -aq' }, + { + cmd: + 'docker run --rm --name=renovate_sidecar --label=renovate_child ' + + '-v "./":"./" ' + + '-e BUILDPACK_CACHE_DIR ' + + '-e CONTAINERBASE_CACHE_DIR ' + + '-w "../.." ' + + 'renovate/sidecar' + + ' bash -l -c "' + + 'install-tool java 17.0.0 ' + + '&& ' + + './mvnw wrapper:wrapper"', + options: { + cwd: '../..', + encoding: 'utf-8', + env: { + HOME: '/home/user', + HTTPS_PROXY: 'https://example.com', + HTTP_PROXY: 'http://example.com', + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + NO_PROXY: 'localhost', + PATH: '/tmp/path', + }, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + + it('Should return null when cmd is not found', async () => { + mockMavenFileChangedInGit('also-not-maven-wrapper.properties'); + jest.spyOn(os, 'platform').mockImplementation(() => 'win32'); + const execSnapshots = mockExecAll({ stdout: '', stderr: '' }); + fs.statLocalFile.mockResolvedValue(null); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { newValue: '3.3.1' }, + }); + expect(updatedDeps).toBeNull(); + expect(execSnapshots).toMatchObject([]); + }); + + it('Should throw an error when it cant execute', async () => { + mockMavenFileChangedInGit(); + mockExecAll(new Error('temporary-error')); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { currentValue: '3.0.0', newValue: '3.3.1' }, + }); + + expect(updatedDeps).toEqual([ + { + artifactError: { + lockFile: 'maven', + stderr: 'temporary-error', + }, + }, + ]); + }); + + it('updates with binarySource install', async () => { + const execSnapshots = mockExecAll({ stdout: '', stderr: '' }); + mockMavenFileChangedInGit(); + GlobalConfig.set({ + localDir: join('/tmp/github/some/repo'), + binarySource: 'install', + }); + const updatedDeps = await updateArtifacts({ + packageFileName: 'maven', + newPackageFileContent: '', + updatedDeps: [{ depName: 'maven-wrapper' }], + config: { currentValue: '3.0.0', newValue: '3.3.1' }, + }); + + expect(execSnapshots).toMatchObject([ + { cmd: 'install-tool java 17.0.0' }, + { + cmd: './mvnw wrapper:wrapper', + options: { + cwd: '/tmp/github', + encoding: 'utf-8', + env: { + HOME: '/home/user', + HTTPS_PROXY: 'https://example.com', + HTTP_PROXY: 'http://example.com', + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + NO_PROXY: 'localhost', + PATH: '/tmp/path', + }, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + + expect(updatedDeps).toEqual([ + { + file: { + contents: undefined, + path: 'maven.mvn/wrapper/maven-wrapper.properties', + type: 'addition', + }, + }, + ]); + }); +}); diff --git a/lib/modules/manager/maven-wrapper/artifacts.ts b/lib/modules/manager/maven-wrapper/artifacts.ts new file mode 100644 index 00000000000000..38c319a7353222 --- /dev/null +++ b/lib/modules/manager/maven-wrapper/artifacts.ts @@ -0,0 +1,221 @@ +import type { Stats } from 'fs'; +import os from 'os'; +import is from '@sindresorhus/is'; +import { dirname, join } from 'upath'; +import { GlobalConfig } from '../../../config/global'; +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { chmodLocalFile, readLocalFile, statLocalFile } from '../../../util/fs'; +import { getRepoStatus } from '../../../util/git'; +import type { StatusResult } from '../../../util/git/types'; +import mavenVersioning from '../../versioning/maven'; +import type { + UpdateArtifact, + UpdateArtifactsConfig, + UpdateArtifactsResult, +} from '../types'; + +interface MavenWrapperPaths { + wrapperExecutableFileName: string; + localProjectDir: string; + wrapperFullyQualifiedPath: string; +} + +async function addIfUpdated( + status: StatusResult, + fileProjectPath: string +): Promise { + if (status.modified.includes(fileProjectPath)) { + return { + file: { + type: 'addition', + path: fileProjectPath, + contents: await readLocalFile(fileProjectPath), + }, + }; + } + return null; +} + +export async function updateArtifacts({ + packageFileName, + newPackageFileContent, + updatedDeps, + config, +}: UpdateArtifact): Promise { + try { + logger.debug({ updatedDeps }, 'maven-wrapper.updateArtifacts()'); + + if (!updatedDeps.some((dep) => dep.depName === 'maven-wrapper')) { + logger.info( + 'Maven wrapper version not updated - skipping Artifacts update' + ); + return null; + } + + const cmd = await createWrapperCommand(packageFileName); + + if (!cmd) { + logger.info('No mvnw found - skipping Artifacts update'); + return null; + } + await executeWrapperCommand(cmd, config, packageFileName); + + const status = await getRepoStatus(); + const artifactFileNames = [ + '.mvn/wrapper/maven-wrapper.properties', + '.mvn/wrapper/maven-wrapper.jar', + '.mvn/wrapper/MavenWrapperDownloader.java', + 'mvnw', + 'mvnw.cmd', + ].map( + (filename) => + packageFileName.replace('.mvn/wrapper/maven-wrapper.properties', '') + + filename + ); + const updateArtifactsResult = ( + await getUpdatedArtifacts(status, artifactFileNames) + ).filter(is.truthy); + + logger.debug( + { files: updateArtifactsResult.map((r) => r.file?.path) }, + `Returning updated maven-wrapper files` + ); + return updateArtifactsResult; + } catch (err) { + logger.debug({ err }, 'Error setting new Maven Wrapper release value'); + return [ + { + artifactError: { + lockFile: packageFileName, + stderr: err.message, + }, + }, + ]; + } +} + +async function getUpdatedArtifacts( + status: StatusResult, + artifactFileNames: string[] +): Promise { + const updatedResults: UpdateArtifactsResult[] = []; + for (const artifactFileName of artifactFileNames) { + const updatedResult = await addIfUpdated(status, artifactFileName); + if (updatedResult !== null) { + updatedResults.push(updatedResult); + } + } + return updatedResults; +} + +/** + * Find compatible java version for maven. + * see https://maven.apache.org/developers/compatibility-plan.html + * @param mavenWrapperVersion current maven version + * @returns A Java semver range + */ +export function getJavaConstraint( + mavenWrapperVersion: string | null | undefined +): string | null { + const major = mavenWrapperVersion + ? mavenVersioning.getMajor(mavenWrapperVersion) + : null; + + if (major && major >= 3) { + return '^17.0.0'; + } + + return '^8.0.0'; +} + +async function executeWrapperCommand( + cmd: string, + config: UpdateArtifactsConfig, + packageFileName: string +): Promise { + logger.debug(`Updating maven wrapper: "${cmd}"`); + const { wrapperFullyQualifiedPath } = getMavenPaths(packageFileName); + const execOptions: ExecOptions = { + cwdFile: wrapperFullyQualifiedPath, + docker: {}, + toolConstraints: [ + { + toolName: 'java', + constraint: + config.constraints?.java ?? getJavaConstraint(config.currentValue), + }, + ], + }; + + try { + await exec(cmd, execOptions); + } catch (err) { + logger.error({ err }, 'Error executing maven wrapper update command.'); + throw err; + } +} + +async function createWrapperCommand( + packageFileName: string +): Promise { + const { + wrapperExecutableFileName, + localProjectDir, + wrapperFullyQualifiedPath, + } = getMavenPaths(packageFileName); + + return await prepareCommand( + wrapperExecutableFileName, + localProjectDir, + await statLocalFile(wrapperFullyQualifiedPath), + 'wrapper:wrapper' + ); +} + +function mavenWrapperFileName(): string { + if ( + os.platform() === 'win32' && + GlobalConfig.get('binarySource') !== 'docker' + ) { + return 'mvnw.cmd'; + } + return './mvnw'; +} + +function getMavenPaths(packageFileName: string): MavenWrapperPaths { + const wrapperExecutableFileName = mavenWrapperFileName(); + const localProjectDir = join(dirname(packageFileName), '../../'); + const wrapperFullyQualifiedPath = join( + localProjectDir, + wrapperExecutableFileName + ); + return { + wrapperExecutableFileName, + localProjectDir, + wrapperFullyQualifiedPath, + }; +} + +async function prepareCommand( + fileName: string, + cwd: string | undefined, + pathFileStats: Stats | null, + args: string | null +): Promise { + // istanbul ignore if + if (pathFileStats?.isFile() === true) { + // if the file is not executable by others + if (os.platform() !== 'win32' && (pathFileStats.mode & 0o1) === 0) { + // add the execution permission to the owner, group and others + logger.warn('Maven wrapper is missing the executable bit'); + await chmodLocalFile(join(cwd, fileName), pathFileStats.mode | 0o111); + } + if (args === null) { + return fileName; + } + return `${fileName} ${args}`; + } + return null; +} diff --git a/lib/modules/manager/maven-wrapper/extract.spec.ts b/lib/modules/manager/maven-wrapper/extract.spec.ts new file mode 100644 index 00000000000000..db4d124b2b9ed0 --- /dev/null +++ b/lib/modules/manager/maven-wrapper/extract.spec.ts @@ -0,0 +1,72 @@ +import { extractPackageFile } from '.'; + +const onlyWrapperProperties = + 'wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar'; +const onlyMavenProperties = + 'distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.4/apache-maven-3.5.4-bin.zip'; + +const wrapperAndMavenProperties = `distributionUrl=https://artifactory.tools.bol.com/artifactory/maven-bol/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip\nwrapperUrl=https://artifactory.tools.bol.com/artifactory/maven-bol/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar`; + +describe('modules/manager/maven-wrapper/extract', () => { + describe('extractPackageFile()', () => { + it('extracts version for property file with distribution type "bin" in distributionUrl', () => { + const res = extractPackageFile(wrapperAndMavenProperties); + expect(res?.deps).toEqual([ + { + currentValue: '3.8.4', + replaceString: + 'https://artifactory.tools.bol.com/artifactory/maven-bol/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip', + datasource: 'maven', + depName: 'maven', + packageName: 'org.apache.maven:apache-maven', + versioning: 'maven', + }, + { + currentValue: '3.1.0', + replaceString: + 'https://artifactory.tools.bol.com/artifactory/maven-bol/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar', + datasource: 'maven', + depName: 'maven-wrapper', + packageName: 'org.apache.maven.wrapper:maven-wrapper', + versioning: 'maven', + }, + ]); + }); + + // takari or maven wrapper ?? + it('extracts version for property file with only a wrapper url', () => { + const res = extractPackageFile(onlyWrapperProperties); + expect(res?.deps).toEqual([ + { + currentValue: '0.5.6', + replaceString: + 'https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar', + datasource: 'maven', + depName: 'maven-wrapper', + packageName: 'org.apache.maven.wrapper:maven-wrapper', + versioning: 'maven', + }, + ]); + }); + + it('extracts version for property file with only a maven url', () => { + const res = extractPackageFile(onlyMavenProperties); + expect(res?.deps).toEqual([ + { + currentValue: '3.5.4', + replaceString: + 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.4/apache-maven-3.5.4-bin.zip', + datasource: 'maven', + depName: 'maven', + packageName: 'org.apache.maven:apache-maven', + versioning: 'maven', + }, + ]); + }); + + it('it should return null when there is no string matching the maven properties regex', () => { + const res = extractPackageFile('nowrapper'); + expect(res).toBeNull(); + }); + }); +}); diff --git a/lib/modules/manager/maven-wrapper/extract.ts b/lib/modules/manager/maven-wrapper/extract.ts new file mode 100644 index 00000000000000..ad052e870fee1e --- /dev/null +++ b/lib/modules/manager/maven-wrapper/extract.ts @@ -0,0 +1,68 @@ +import { logger } from '../../../logger'; +import { newlineRegex, regEx } from '../../../util/regex'; +import { MavenDatasource } from '../../datasource/maven'; +import { id as versioning } from '../../versioning/maven'; +import type { PackageDependency, PackageFile } from '../types'; +import type { MavenVersionExtract, Version } from './types'; + +// https://regex101.com/r/IcOs7P/1 +const DISTRIBUTION_URL_REGEX = regEx( + '^(?:distributionUrl\\s*=\\s*)(?\\S*-(?\\d+\\.\\d+(?:\\.\\d+)?(?:-\\w+)*)-(?bin|all)\\.zip)\\s*$' +); + +const WRAPPER_URL_REGEX = regEx( + '^(?:wrapperUrl\\s*=\\s*)(?\\S*-(?\\d+\\.\\d+(?:\\.\\d+)?(?:-\\w+)*)(?:.jar))' +); + +function extractVersions(fileContent: string): MavenVersionExtract { + const lines = fileContent?.split(newlineRegex) ?? []; + const maven = extractLineInfo(lines, DISTRIBUTION_URL_REGEX) ?? undefined; + const wrapper = extractLineInfo(lines, WRAPPER_URL_REGEX) ?? undefined; + return { maven, wrapper }; +} + +function extractLineInfo(lines: string[], regex: RegExp): Version | null { + for (const line of lines) { + if (line.match(regex)) { + const match = regex.exec(line); + if (match?.groups) { + return { + url: match.groups.url, + version: match.groups.version, + }; + } + } + } + return null; +} + +export function extractPackageFile(fileContent: string): PackageFile | null { + logger.trace('maven-wrapper.extractPackageFile()'); + const extractResult = extractVersions(fileContent); + const deps = []; + + if (extractResult.maven?.version) { + const maven: PackageDependency = { + depName: 'maven', + packageName: 'org.apache.maven:apache-maven', + currentValue: extractResult.maven?.version, + replaceString: extractResult.maven?.url, + datasource: MavenDatasource.id, + versioning, + }; + deps.push(maven); + } + + if (extractResult.wrapper?.version) { + const wrapper: PackageDependency = { + depName: 'maven-wrapper', + packageName: 'org.apache.maven.wrapper:maven-wrapper', + currentValue: extractResult.wrapper?.version, + replaceString: extractResult.wrapper?.url, + datasource: MavenDatasource.id, + versioning, + }; + deps.push(wrapper); + } + return deps.length ? { deps } : null; +} diff --git a/lib/modules/manager/maven-wrapper/index.ts b/lib/modules/manager/maven-wrapper/index.ts new file mode 100644 index 00000000000000..9fc60c9a4a0ce9 --- /dev/null +++ b/lib/modules/manager/maven-wrapper/index.ts @@ -0,0 +1,12 @@ +import { MavenDatasource } from '../../datasource/maven'; +import { id as versioning } from '../../versioning/maven'; + +export { extractPackageFile } from './extract'; +export { updateArtifacts } from './artifacts'; + +export const defaultConfig = { + fileMatch: ['(^|\\/).mvn/wrapper/maven-wrapper.properties$'], + versioning, +}; + +export const supportedDatasources = [MavenDatasource.id]; diff --git a/lib/modules/manager/maven-wrapper/readme.md b/lib/modules/manager/maven-wrapper/readme.md new file mode 100644 index 00000000000000..c28108936164d2 --- /dev/null +++ b/lib/modules/manager/maven-wrapper/readme.md @@ -0,0 +1,2 @@ +Configuration for Maven Wrapper updates. +Changes here affect how Renovate updates the version of Maven in the wrapper, not how it uses the wrapper. diff --git a/lib/modules/manager/maven-wrapper/types.ts b/lib/modules/manager/maven-wrapper/types.ts new file mode 100644 index 00000000000000..ef6bae40b030c5 --- /dev/null +++ b/lib/modules/manager/maven-wrapper/types.ts @@ -0,0 +1,9 @@ +export interface Version { + url: string; + version: string; +} + +export interface MavenVersionExtract { + maven?: Version; + wrapper?: Version; +}