diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index d924c13b359e9f..25233dd0663ccd 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1052,6 +1052,7 @@ const options: RenovateOptions[] = [ 'kubernetes', 'kustomize', 'terraform', + 'vendir', 'woodpecker', ], }, diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 6f618abaee49d2..94821d957d6161 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -86,6 +86,7 @@ import * as tflintPlugin from './tflint-plugin'; import * as travis from './travis'; import type { ManagerApi } from './types'; import * as velaci from './velaci'; +import * as vendir from './vendir'; import * as woodpecker from './woodpecker'; const api = new Map(); @@ -178,4 +179,5 @@ api.set('terragrunt-version', terragruntVersion); api.set('tflint-plugin', tflintPlugin); api.set('travis', travis); api.set('velaci', velaci); +api.set('vendir', vendir); api.set('woodpecker', woodpecker); diff --git a/lib/modules/manager/vendir/__fixtures__/alias-contents.yaml b/lib/modules/manager/vendir/__fixtures__/alias-contents.yaml new file mode 100644 index 00000000000000..1090312b61a6de --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/alias-contents.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config +directories: +- path: vendor + contents: + - path: custom-repo-custom-version + helmChart: + name: oci + version: "7.10.1" + repository: + url: oci://test diff --git a/lib/modules/manager/vendir/__fixtures__/multiple-contents.yaml b/lib/modules/manager/vendir/__fixtures__/multiple-contents.yaml new file mode 100644 index 00000000000000..05ecd9fb0e2963 --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/multiple-contents.yaml @@ -0,0 +1,17 @@ +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config +directories: +- path: vendor + contents: + - path: custom-repo-custom-version + helmChart: + name: contour + version: "7.10.1" + repository: + url: https://charts.bitnami.com/bitnami + - path: thing + helmChart: + name: contour + version: "7.10.1" + repository: + url: https://charts.bitnami.com/bitnami diff --git a/lib/modules/manager/vendir/__fixtures__/non-helmchart.yaml b/lib/modules/manager/vendir/__fixtures__/non-helmchart.yaml new file mode 100644 index 00000000000000..738c9257cad17f --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/non-helmchart.yaml @@ -0,0 +1,12 @@ +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config +directories: +- path: vendor + contents: + - path: github.com/cloudfoundry/cf-k8s-networking + git: + # http or ssh urls are supported (required) + url: https://github.com/cloudfoundry/cf-k8s-networking + # branch, tag, commit; origin is the name of the remote (required) + # optional if refSelection is specified (available in v0.11.0+) + ref: origin/master diff --git a/lib/modules/manager/vendir/__fixtures__/oci-contents.yaml b/lib/modules/manager/vendir/__fixtures__/oci-contents.yaml new file mode 100644 index 00000000000000..d34ad87fb52c64 --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/oci-contents.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config +directories: +- path: vendor + contents: + - path: custom-repo-custom-version + helmChart: + name: contour + version: "7.10.1" + repository: + url: oci://charts.bitnami.com/bitnami diff --git a/lib/modules/manager/vendir/__fixtures__/one-contents.yaml b/lib/modules/manager/vendir/__fixtures__/one-contents.yaml new file mode 100644 index 00000000000000..db27125f6aae48 --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/one-contents.yaml @@ -0,0 +1,11 @@ +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config +directories: +- path: vendor + contents: + - path: custom-repo-custom-version + helmChart: + name: contour + version: "7.10.1" + repository: + url: https://charts.bitnami.com/bitnami diff --git a/lib/modules/manager/vendir/__fixtures__/vendir.yml b/lib/modules/manager/vendir/__fixtures__/vendir.yml new file mode 100644 index 00000000000000..ca496ff089bf08 --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/vendir.yml @@ -0,0 +1,15 @@ +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config + +minimumRequiredVersion: 0.32.0 + +# one or more directories to manage with vendir +directories: + - path: vendor + contents: + - path: renovate + helmChart: + name: renovate + version: 36.109.4 + repository: + url: https://docs.renovatebot.com/helm-charts diff --git a/lib/modules/manager/vendir/__fixtures__/vendir_1.lock b/lib/modules/manager/vendir/__fixtures__/vendir_1.lock new file mode 100644 index 00000000000000..49242c2e9efae3 --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/vendir_1.lock @@ -0,0 +1,9 @@ +apiVersion: vendir.k14s.io/v1alpha1 +directories: +- contents: + - helmChart: + appVersion: 36.109.4 + version: 36.109.4 + path: renovate + path: vendor +kind: LockConfig diff --git a/lib/modules/manager/vendir/__fixtures__/vendir_2.lock b/lib/modules/manager/vendir/__fixtures__/vendir_2.lock new file mode 100644 index 00000000000000..76db684b7cd8bf --- /dev/null +++ b/lib/modules/manager/vendir/__fixtures__/vendir_2.lock @@ -0,0 +1,9 @@ +apiVersion: vendir.k14s.io/v1alpha1 +directories: +- contents: + - helmChart: + appVersion: 36.109.4 + version: 37.109.4 + path: renovate + path: vendor +kind: LockConfig diff --git a/lib/modules/manager/vendir/__snapshots__/artifacts.spec.ts.snap b/lib/modules/manager/vendir/__snapshots__/artifacts.spec.ts.snap new file mode 100644 index 00000000000000..059191726c0413 --- /dev/null +++ b/lib/modules/manager/vendir/__snapshots__/artifacts.spec.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modules/manager/vendir/artifacts returns null if unchanged 1`] = ` +[ + { + "cmd": "vendir sync", + "options": { + "cwd": "/tmp/github/some/repo", + "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, + }, + }, +] +`; diff --git a/lib/modules/manager/vendir/artifacts.spec.ts b/lib/modules/manager/vendir/artifacts.spec.ts new file mode 100644 index 00000000000000..32d7ce8efdc23b --- /dev/null +++ b/lib/modules/manager/vendir/artifacts.spec.ts @@ -0,0 +1,514 @@ +import { mockDeep } from 'jest-mock-extended'; +import { join } from 'upath'; +import { envMock, mockExecAll } from '../../../../test/exec-util'; +import { Fixtures } from '../../../../test/fixtures'; +import { env, fs, git, partial } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import type { RepoGlobalConfig } from '../../../config/types'; +import { TEMPORARY_ERROR } from '../../../constants/error-messages'; +import { ExecError } from '../../../util/exec/exec-error'; +import type { StatusResult } from '../../../util/git/types'; +import type { UpdateArtifactsConfig } from '../types'; +import * as vendir from '.'; + +process.env.CONTAINERBASE = 'true'; + +jest.mock('../../datasource', () => mockDeep()); +jest.mock('../../../util/exec/env', () => mockDeep()); +jest.mock('../../../util/http', () => mockDeep()); +jest.mock('../../../util/fs', () => mockDeep()); +jest.mock('../../../util/git', () => mockDeep()); + +const adminConfig: RepoGlobalConfig = { + localDir: join('/tmp/github/some/repo'), // `join` fixes Windows CI + cacheDir: join('/tmp/renovate/cache'), + containerbaseDir: join('/tmp/cache/containerbase'), + dockerSidecarImage: 'ghcr.io/containerbase/sidecar', +}; + +const config: UpdateArtifactsConfig = {}; +const vendirLockFile1 = Fixtures.get('vendir_1.lock'); +const vendirLockFile2 = Fixtures.get('vendir_2.lock'); +const vendirFile = Fixtures.get('vendir.yml'); + +describe('modules/manager/vendir/artifacts', () => { + beforeEach(() => { + env.getChildProcessEnv.mockReturnValue(envMock.basic); + GlobalConfig.set(adminConfig); + }); + + it('returns null if no vendir.lock.yml found', async () => { + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toBeNull(); + }); + + it('returns null if empty vendir.lock.yml found', async () => { + const updatedDeps = [{ depName: 'dep1' }]; + fs.readLocalFile.mockResolvedValueOnce(''); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: '', + config, + }), + ).toBeNull(); + }); + + it('returns null if updatedDeps is empty', async () => { + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.lock.yml', + updatedDeps: [], + newPackageFileContent: '', + config, + }), + ).toBeNull(); + }); + + it('returns null if unchanged', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce(vendirFile); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config, + }), + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot([{ cmd: 'vendir sync' }]); + }); + + it('returns updated vendir.lock', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.lock.yml', + contents: vendirLockFile2, + }, + }, + ]); + expect(execSnapshots).toMatchObject([{ cmd: 'vendir sync' }]); + }); + + it('returns updated vendir.yml for lockfile maintenance', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.yml'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps: [], + newPackageFileContent: vendirFile, + config: { ...config, updateType: 'lockFileMaintenance' }, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.yml', + contents: vendirLockFile2, + }, + }, + ]); + expect(execSnapshots).toMatchObject([{ cmd: 'vendir sync' }]); + }); + + it('catches errors', async () => { + fs.getSiblingFileName.mockReturnValueOnce('vendir.yml'); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.writeLocalFile.mockImplementationOnce(() => { + throw new Error('not found'); + }); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config, + }), + ).toEqual([ + { + artifactError: { + lockFile: 'vendir.yml', + stderr: 'not found', + }, + }, + ]); + }); + + it('rethrows for temporary error', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.yml'); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + const execError = new ExecError(TEMPORARY_ERROR, { + cmd: '', + stdout: '', + stderr: '', + options: { encoding: 'utf8' }, + }); + const updatedDeps = [{ depName: 'dep1' }]; + mockExecAll(execError); + await expect( + vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config, + }), + ).rejects.toThrow(TEMPORARY_ERROR); + }); + + it('add artifacts to file list if vendir.yml exists', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + + // artifacts + fs.getSiblingFileName.mockReturnValueOnce('vendor'); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + not_added: ['vendor/Chart.yaml', 'vendor/my-chart/Chart.yaml'], + deleted: ['vendor/removed.yaml'], + }), + ); + const updatedDeps = [{ depName: 'dep1' }]; + const test = await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config: { + ...config, + }, + }); + expect(test).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.lock.yml', + contents: vendirLockFile2, + }, + }, + { + file: { + type: 'addition', + path: 'vendor/Chart.yaml', + contents: undefined, + }, + }, + { + file: { + type: 'addition', + path: 'vendor/my-chart/Chart.yaml', + contents: undefined, + }, + }, + { + file: { + type: 'deletion', + path: 'vendor/removed.yaml', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'vendir sync', + options: { + 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', + }, + }, + }, + ]); + }); + + it('add artifacts', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + const execSnapshots = mockExecAll(); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + + // artifacts + fs.getSiblingFileName.mockReturnValueOnce('vendor'); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + not_added: ['vendor/Chart.yaml'], + }), + ); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config: { + ...config, + }, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.lock.yml', + contents: vendirLockFile2, + }, + }, + { + file: { + type: 'addition', + path: 'vendor/Chart.yaml', + contents: undefined, + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'vendir sync', + options: { + 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', + }, + }, + }, + ]); + }); + + it('works explicit global binarySource', async () => { + GlobalConfig.set({ ...adminConfig, binarySource: 'global' }); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.lock.yml', + contents: vendirLockFile2, + }, + }, + ]); + expect(execSnapshots).toMatchObject([{ cmd: 'vendir sync' }]); + }); + + it('supports install mode', async () => { + GlobalConfig.set({ + ...adminConfig, + binarySource: 'install', + }); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + const execSnapshots = mockExecAll(); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config: { + ...config, + constraints: { vendir: '0.35.0', helm: '3.17.0' }, + }, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.lock.yml', + contents: vendirLockFile2, + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'install-tool vendir 0.35.0', + options: { + 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', + }, + }, + }, + { + cmd: 'install-tool helm 3.17.0', + options: { + 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', + }, + }, + }, + { + cmd: 'vendir sync', + options: { + 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', + }, + }, + }, + ]); + }); + + describe('Docker', () => { + beforeEach(() => { + GlobalConfig.set({ + ...adminConfig, + binarySource: 'docker', + }); + }); + + it('returns updated vendir.yml for lockfile maintenance', async () => { + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile1); + fs.getSiblingFileName.mockReturnValueOnce('vendir.lock.yml'); + fs.readLocalFile.mockResolvedValueOnce(vendirLockFile2); + fs.readLocalFile.mockResolvedValueOnce('0.35.0'); + const execSnapshots = mockExecAll(); + fs.privateCacheDir.mockReturnValue( + '/tmp/renovate/cache/__renovate-private-cache', + ); + fs.getParentDir.mockReturnValue(''); + const updatedDeps = [{ depName: 'dep1' }]; + expect( + await vendir.updateArtifacts({ + packageFileName: 'vendir.yml', + updatedDeps, + newPackageFileContent: vendirFile, + config: { + ...config, + constraints: { vendir: '0.35.0', helm: '3.17.0' }, + }, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'vendir.lock.yml', + contents: vendirLockFile2, + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { cmd: 'docker pull ghcr.io/containerbase/sidecar' }, + { cmd: 'docker ps --filter name=renovate_sidecar -aq' }, + { + cmd: + 'docker run --rm --name=renovate_sidecar --label=renovate_child ' + + '-v "/tmp/github/some/repo":"/tmp/github/some/repo" ' + + '-v "/tmp/renovate/cache":"/tmp/renovate/cache" ' + + '-v "/tmp/cache/containerbase":"/tmp/cache/containerbase" ' + + '-e CONTAINERBASE_CACHE_DIR ' + + '-w "/tmp/github/some/repo" ' + + 'ghcr.io/containerbase/sidecar' + + ' bash -l -c "' + + 'install-tool vendir 0.35.0' + + ' && ' + + 'install-tool helm 3.17.0' + + ' && ' + + 'vendir sync' + + '"', + }, + ]); + }); + }); +}); diff --git a/lib/modules/manager/vendir/artifacts.ts b/lib/modules/manager/vendir/artifacts.ts new file mode 100644 index 00000000000000..17db506dd5d7b9 --- /dev/null +++ b/lib/modules/manager/vendir/artifacts.ts @@ -0,0 +1,113 @@ +import { TEMPORARY_ERROR } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { + getParentDir, + getSiblingFileName, + readLocalFile, + writeLocalFile, +} from '../../../util/fs'; +import { getRepoStatus } from '../../../util/git'; +import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; + +export async function updateArtifacts({ + packageFileName, + updatedDeps, + newPackageFileContent, + config, +}: UpdateArtifact): Promise { + logger.debug(`vendir.updateArtifacts(${packageFileName})`); + + const lockFileName = getSiblingFileName(packageFileName, 'vendir.lock.yml'); + if (!lockFileName) { + logger.warn('No vendir.lock.yml found'); + return null; + } + const existingLockFileContent = await readLocalFile(lockFileName, 'utf8'); + if (!existingLockFileContent) { + logger.warn('Empty vendir.lock.yml found'); + return null; + } + + try { + await writeLocalFile(packageFileName, newPackageFileContent); + logger.debug('Updating Vendir artifacts'); + const execOptions: ExecOptions = { + cwdFile: packageFileName, + docker: {}, + toolConstraints: [ + { toolName: 'vendir', constraint: config.constraints?.vendir }, + { toolName: 'helm', constraint: config.constraints?.helm }, + ], + }; + + await exec(`vendir sync`, execOptions); + + logger.debug('Returning updated Vendir artifacts'); + + const fileChanges: UpdateArtifactsResult[] = []; + + const newVendirLockContent = await readLocalFile(lockFileName, 'utf8'); + const isLockFileChanged = existingLockFileContent !== newVendirLockContent; + if (isLockFileChanged) { + fileChanges.push({ + file: { + type: 'addition', + path: lockFileName, + contents: newVendirLockContent, + }, + }); + } + + // add modified vendir archives to artifacts + logger.debug("Adding Sync'd files to git"); + // Files must be in the vendor path to get added + const vendorDir = getParentDir(packageFileName); + const status = await getRepoStatus(); + if (status) { + const modifiedFiles = status.modified ?? []; + const notAddedFiles = status.not_added; + const deletedFiles = status.deleted ?? []; + + for (const f of modifiedFiles.concat(notAddedFiles)) { + const isFileInVendorDir = f.startsWith(vendorDir); + if (vendorDir || isFileInVendorDir) { + fileChanges.push({ + file: { + type: 'addition', + path: f, + contents: await readLocalFile(f), + }, + }); + } + } + + for (const f of deletedFiles) { + fileChanges.push({ + file: { + type: 'deletion', + path: f, + }, + }); + } + } else { + logger.error('Failed to get git status'); + } + + return fileChanges.length ? fileChanges : null; + } catch (err) { + if (err.message === TEMPORARY_ERROR) { + throw err; + } + logger.debug({ err }, 'Failed to update Vendir lock file'); + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } +} diff --git a/lib/modules/manager/vendir/extract.spec.ts b/lib/modules/manager/vendir/extract.spec.ts new file mode 100644 index 00000000000000..a4d612c47322f3 --- /dev/null +++ b/lib/modules/manager/vendir/extract.spec.ts @@ -0,0 +1,108 @@ +import { codeBlock } from 'common-tags'; +import { Fixtures } from '../../../../test/fixtures'; +import { extractPackageFile } from '.'; + +const oneContents = Fixtures.get('one-contents.yaml'); +const ociContents = Fixtures.get('oci-contents.yaml'); +const aliasContents = Fixtures.get('alias-contents.yaml'); +const multipleContents = Fixtures.get('multiple-contents.yaml'); +const nonHelmChartContents = Fixtures.get('non-helmchart.yaml'); + +describe('modules/manager/vendir/extract', () => { + describe('extractPackageFile()', () => { + it('returns null for invalid yaml file content', () => { + const result = extractPackageFile('nothing here: [', 'vendir.yml', {}); + expect(result).toBeNull(); + }); + + it('returns null for empty yaml file content', () => { + const result = extractPackageFile('', 'vendir.yml', {}); + expect(result).toBeNull(); + }); + + it('returns null for empty directories key', () => { + const emptyDirectories = codeBlock` + apiVersion: vendir.k14s.io/v1alpha1 + kind: Config + directories: [] + `; + const result = extractPackageFile(emptyDirectories, 'vendir.yml', {}); + expect(result).toBeNull(); + }); + + it('returns null for nonHelmChart key', () => { + const result = extractPackageFile(nonHelmChartContents, 'vendir.yml', {}); + expect(result).toBeNull(); + }); + + it('single chart - extracts helm-chart from vendir.yml correctly', () => { + const result = extractPackageFile(oneContents, 'vendir.yml', {}); + expect(result).toMatchObject({ + deps: [ + { + currentValue: '7.10.1', + depName: 'contour', + datasource: 'helm', + registryUrls: ['https://charts.bitnami.com/bitnami'], + }, + ], + }); + }); + + it('single chart - extracts oci helm-chart from vendir.yml correctly', () => { + const result = extractPackageFile(ociContents, 'vendir.yml', {}); + expect(result).toMatchObject({ + deps: [ + { + currentValue: '7.10.1', + depName: 'contour', + packageName: 'charts.bitnami.com/bitnami/contour', + datasource: 'docker', + }, + ], + }); + }); + + it('multiple charts - extracts helm-chart from vendir.yml correctly', () => { + const result = extractPackageFile(multipleContents, 'vendir.yml', {}); + expect(result).toMatchObject({ + deps: [ + { + currentValue: '7.10.1', + depName: 'contour', + datasource: 'helm', + registryUrls: ['https://charts.bitnami.com/bitnami'], + }, + { + currentValue: '7.10.1', + depName: 'contour', + datasource: 'helm', + registryUrls: ['https://charts.bitnami.com/bitnami'], + }, + ], + }); + }); + + it('resolves aliased registry urls', () => { + const aliasResult = extractPackageFile(aliasContents, 'vendir.yml', { + registryAliases: { + test: 'quay.example.com/organization', + }, + }); + + expect(aliasResult).toMatchObject({ + deps: [ + { + currentDigest: undefined, + currentValue: '7.10.1', + depName: 'oci', + datasource: 'docker', + depType: 'HelmChart', + packageName: 'quay.example.com/organization/oci', + pinDigests: false, + }, + ], + }); + }); + }); +}); diff --git a/lib/modules/manager/vendir/extract.ts b/lib/modules/manager/vendir/extract.ts new file mode 100644 index 00000000000000..94049a52ae628b --- /dev/null +++ b/lib/modules/manager/vendir/extract.ts @@ -0,0 +1,87 @@ +import { logger } from '../../../logger'; +import { parseSingleYaml } from '../../../util/yaml'; +import { HelmDatasource } from '../../datasource/helm'; +import { getDep } from '../dockerfile/extract'; +import { isOCIRegistry } from '../helmv3/utils'; +import type { + ExtractConfig, + PackageDependency, + PackageFileContent, +} from '../types'; +import { HelmChartDefinition, Vendir, VendirDefinition } from './schema'; + +// TODO: Add support for other vendir types (like git tags, github releases, etc.) +// Recommend looking at the kustomize manager for more information on support. + +export function extractHelmChart( + helmChart: HelmChartDefinition, + aliases?: Record | undefined, +): PackageDependency | null { + if (isOCIRegistry(helmChart.repository.url)) { + const dep = getDep( + `${helmChart.repository.url.replace('oci://', '')}/${helmChart.name}:${helmChart.version}`, + false, + aliases, + ); + return { + ...dep, + depName: helmChart.name, + packageName: dep.depName, + // https://github.com/helm/helm/issues/10312 + // https://github.com/helm/helm/issues/10678 + pinDigests: false, + }; + } + return { + depName: helmChart.name, + currentValue: helmChart.version, + registryUrls: [helmChart.repository.url], + datasource: HelmDatasource.id, + }; +} + +export function parseVendir( + content: string, + packageFile?: string, +): VendirDefinition | null { + try { + return parseSingleYaml(content, { + customSchema: Vendir, + removeTemplates: true, + }); + } catch (e) { + logger.debug({ packageFile }, 'Error parsing vendir.yml file'); + return null; + } +} + +export function extractPackageFile( + content: string, + packageFile: string, + config: ExtractConfig, +): PackageFileContent | null { + logger.trace(`vendir.extractPackageFile(${packageFile})`); + const deps: PackageDependency[] = []; + + const pkg = parseVendir(content, packageFile); + if (!pkg) { + return null; + } + + // grab the helm charts + const contents = pkg.directories.flatMap((directory) => directory.contents); + for (const content of contents) { + const dep = extractHelmChart(content.helmChart, config.registryAliases); + if (dep) { + deps.push({ + ...dep, + depType: 'HelmChart', + }); + } + } + + if (!deps.length) { + return null; + } + return { deps }; +} diff --git a/lib/modules/manager/vendir/index.ts b/lib/modules/manager/vendir/index.ts new file mode 100644 index 00000000000000..aaab07563e9f87 --- /dev/null +++ b/lib/modules/manager/vendir/index.ts @@ -0,0 +1,12 @@ +import { DockerDatasource } from '../../datasource/docker'; +import { HelmDatasource } from '../../datasource/helm'; +export { extractPackageFile } from './extract'; +export { updateArtifacts } from './artifacts'; + +export const defaultConfig = { + commitMessageTopic: 'vendir {{depName}}', + fileMatch: ['(^|/)vendir\\.yml$'], +}; + +export const supportedDatasources = [HelmDatasource.id, DockerDatasource.id]; +export const supportsLockFileMaintenance = true; diff --git a/lib/modules/manager/vendir/readme.md b/lib/modules/manager/vendir/readme.md new file mode 100644 index 00000000000000..3c7eaf0f97e591 --- /dev/null +++ b/lib/modules/manager/vendir/readme.md @@ -0,0 +1,34 @@ +Renovate supports updating Helm Chart references in vendir.yml via the [vendir](https://carvel.dev/vendir/) tool. Renovate requires the presence of a [vendir lock file](https://carvel.dev/vendir/docs/v0.40.x/vendir-lock-spec/) which is generated by vendir and should be stored in source code. + +It supports both https and oci helm chart repositories. + +```yaml title="Example vendir.yml" +apiVersion: vendir.k14s.io/v1alpha1 +kind: Config + +# one or more directories to manage with vendir +directories: + - # path is relative to `vendir` CLI working directory + path: config/_ytt_lib + contents: + path: github.com/cloudfoundry/cf-k8s-networking + helmChart: + # chart name (required) + name: stable/redis + # use specific chart version (string; optional) + version: '1.2.1' + # specifies Helm repository to fetch from (optional) + repository: + # repository url; supports exprimental oci helm fetch via + # oci:// scheme (required) + url: https://... + # specify helm binary version to use; + # '3' means binary 'helm3' needs to be on the path (optional) + helmVersion: '3' +``` + +### Registry Aliases + +#### OCI + +Aliases for OCI registries are supported via the dockerfile/docker manager diff --git a/lib/modules/manager/vendir/schema.ts b/lib/modules/manager/vendir/schema.ts new file mode 100644 index 00000000000000..8e761a450a01e7 --- /dev/null +++ b/lib/modules/manager/vendir/schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { LooseArray } from '../../../util/schema-utils'; + +export const VendirResource = z.object({ + apiVersion: z.literal('vendir.k14s.io/v1alpha1'), + kind: z.literal('Config'), +}); + +export const HelmChart = z.object({ + name: z.string(), + version: z.string(), + repository: z.object({ + url: z.string().regex(/^(?:oci|https?):\/\/.+/), + }), +}); + +export const Contents = z.object({ + path: z.string(), + helmChart: HelmChart, +}); + +export const Vendir = VendirResource.extend({ + directories: z.array( + z.object({ + path: z.string(), + contents: LooseArray(Contents), + }), + ), +}); + +export type VendirDefinition = z.infer; +export type HelmChartDefinition = z.infer; diff --git a/lib/modules/manager/vendir/types.ts b/lib/modules/manager/vendir/types.ts new file mode 100644 index 00000000000000..d80b8aedd1b5cb --- /dev/null +++ b/lib/modules/manager/vendir/types.ts @@ -0,0 +1,36 @@ +import type { HostRule } from '../../../types'; + +export interface Vendir { + kind?: string; + directories: Directories[]; +} + +export interface Directories { + path: string; + contents: Contents[]; +} + +export type Contents = HelmChartContent | OtherContent; + +export interface HelmChartContent { + path: string; + helmChart: HelmChart; +} + +export interface OtherContent { + path: string; +} + +export interface HelmChart { + name: string; + version: string; + repository: Repository; +} + +export interface Repository { + url: string; +} + +export interface RepositoryRule extends Repository { + hostRule: HostRule; +} diff --git a/lib/util/exec/containerbase.ts b/lib/util/exec/containerbase.ts index 73111067539ba1..1b5585effc6410 100644 --- a/lib/util/exec/containerbase.ts +++ b/lib/util/exec/containerbase.ts @@ -196,6 +196,11 @@ const allToolConfig: Record = { packageName: 'flutter', versioning: npmVersioningId, }, + vendir: { + datasource: 'github-releases', + packageName: 'carvel-dev/vendir', + versioning: semverVersioningId, + }, }; let _getPkgReleases: Promise | null =