From db1b0d8b66a4772eec260254fd9bf93f2b2bf0a4 Mon Sep 17 00:00:00 2001
From: Hans Knecht <Hans.Knechtions@gmail.com>
Date: Thu, 21 Mar 2024 16:46:10 +0100
Subject: [PATCH] feat(vendir): add vendir support (#25113)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Zoltán Reegn <reegnz@users.noreply.github.com>
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Zoltán Reegn <zoltan.reegn@gmail.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 lib/config/options/index.ts                   |   1 +
 lib/modules/manager/api.ts                    |   2 +
 .../vendir/__fixtures__/alias-contents.yaml   |  12 +
 .../__fixtures__/multiple-contents.yaml       |  17 +
 .../vendir/__fixtures__/non-helmchart.yaml    |  12 +
 .../vendir/__fixtures__/oci-contents.yaml     |  12 +
 .../vendir/__fixtures__/one-contents.yaml     |  11 +
 .../manager/vendir/__fixtures__/vendir.yml    |  15 +
 .../manager/vendir/__fixtures__/vendir_1.lock |   9 +
 .../manager/vendir/__fixtures__/vendir_2.lock |   9 +
 .../__snapshots__/artifacts.spec.ts.snap      |  24 +
 lib/modules/manager/vendir/artifacts.spec.ts  | 514 ++++++++++++++++++
 lib/modules/manager/vendir/artifacts.ts       | 113 ++++
 lib/modules/manager/vendir/extract.spec.ts    | 108 ++++
 lib/modules/manager/vendir/extract.ts         |  87 +++
 lib/modules/manager/vendir/index.ts           |  12 +
 lib/modules/manager/vendir/readme.md          |  34 ++
 lib/modules/manager/vendir/schema.ts          |  32 ++
 lib/modules/manager/vendir/types.ts           |  36 ++
 lib/util/exec/containerbase.ts                |   5 +
 20 files changed, 1065 insertions(+)
 create mode 100644 lib/modules/manager/vendir/__fixtures__/alias-contents.yaml
 create mode 100644 lib/modules/manager/vendir/__fixtures__/multiple-contents.yaml
 create mode 100644 lib/modules/manager/vendir/__fixtures__/non-helmchart.yaml
 create mode 100644 lib/modules/manager/vendir/__fixtures__/oci-contents.yaml
 create mode 100644 lib/modules/manager/vendir/__fixtures__/one-contents.yaml
 create mode 100644 lib/modules/manager/vendir/__fixtures__/vendir.yml
 create mode 100644 lib/modules/manager/vendir/__fixtures__/vendir_1.lock
 create mode 100644 lib/modules/manager/vendir/__fixtures__/vendir_2.lock
 create mode 100644 lib/modules/manager/vendir/__snapshots__/artifacts.spec.ts.snap
 create mode 100644 lib/modules/manager/vendir/artifacts.spec.ts
 create mode 100644 lib/modules/manager/vendir/artifacts.ts
 create mode 100644 lib/modules/manager/vendir/extract.spec.ts
 create mode 100644 lib/modules/manager/vendir/extract.ts
 create mode 100644 lib/modules/manager/vendir/index.ts
 create mode 100644 lib/modules/manager/vendir/readme.md
 create mode 100644 lib/modules/manager/vendir/schema.ts
 create mode 100644 lib/modules/manager/vendir/types.ts

diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index 5aa8c5cec0..74df768ae9 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 6f618abaee..94821d957d 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<string, ManagerApi>();
@@ -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 0000000000..1090312b61
--- /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 0000000000..05ecd9fb0e
--- /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 0000000000..738c9257ca
--- /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 0000000000..d34ad87fb5
--- /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 0000000000..db27125f6a
--- /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 0000000000..ca496ff089
--- /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 0000000000..49242c2e9e
--- /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 0000000000..76db684b7c
--- /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 0000000000..059191726c
--- /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 0000000000..32d7ce8efd
--- /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<StatusResult>({
+        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<StatusResult>({
+        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 0000000000..17db506dd5
--- /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<UpdateArtifactsResult[] | null> {
+  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 0000000000..a4d612c473
--- /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 0000000000..94049a52ae
--- /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<string, string> | 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 0000000000..aaab07563e
--- /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 0000000000..3c7eaf0f97
--- /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 0000000000..8e761a450a
--- /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<typeof Vendir>;
+export type HelmChartDefinition = z.infer<typeof HelmChart>;
diff --git a/lib/modules/manager/vendir/types.ts b/lib/modules/manager/vendir/types.ts
new file mode 100644
index 0000000000..d80b8aedd1
--- /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 7311106753..1b5585effc 100644
--- a/lib/util/exec/containerbase.ts
+++ b/lib/util/exec/containerbase.ts
@@ -196,6 +196,11 @@ const allToolConfig: Record<string, ToolConfig> = {
     packageName: 'flutter',
     versioning: npmVersioningId,
   },
+  vendir: {
+    datasource: 'github-releases',
+    packageName: 'carvel-dev/vendir',
+    versioning: semverVersioningId,
+  },
 };
 
 let _getPkgReleases: Promise<typeof import('../../modules/datasource')> | null =
-- 
GitLab