From d2d356c801b16de31f56451093a4613cc6400e05 Mon Sep 17 00:00:00 2001
From: Andrei Nistor <andrei_nistor@smart-x.net>
Date: Mon, 22 Nov 2021 17:36:48 +0200
Subject: [PATCH] feat: Add jsonnet-bundler support (#12720)

---
 docs/usage/configuration-options.md           |  11 +-
 lib/manager/api.ts                            |   2 +
 .../jsonnetfile-local-dependencies.json       |  15 ++
 .../jsonnetfile-no-dependencies.json          |   5 +
 .../__fixtures__/jsonnetfile-with-name.json   |  16 ++
 .../__fixtures__/jsonnetfile.json             |  24 +++
 .../__snapshots__/artifacts.spec.ts.snap      | 129 ++++++++++++
 .../__snapshots__/extract.spec.ts.snap        |  39 ++++
 lib/manager/jsonnet-bundler/artifacts.spec.ts | 196 ++++++++++++++++++
 lib/manager/jsonnet-bundler/artifacts.ts      | 110 ++++++++++
 lib/manager/jsonnet-bundler/extract.spec.ts   |  68 ++++++
 lib/manager/jsonnet-bundler/extract.ts        |  57 +++++
 lib/manager/jsonnet-bundler/index.ts          |  10 +
 lib/manager/jsonnet-bundler/readme.md         |   5 +
 lib/manager/jsonnet-bundler/types.ts          |  20 ++
 lib/util/exec/buildpack.ts                    |   6 +
 16 files changed, 712 insertions(+), 1 deletion(-)
 create mode 100644 lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-local-dependencies.json
 create mode 100644 lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-no-dependencies.json
 create mode 100644 lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-with-name.json
 create mode 100644 lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile.json
 create mode 100644 lib/manager/jsonnet-bundler/__snapshots__/artifacts.spec.ts.snap
 create mode 100644 lib/manager/jsonnet-bundler/__snapshots__/extract.spec.ts.snap
 create mode 100644 lib/manager/jsonnet-bundler/artifacts.spec.ts
 create mode 100644 lib/manager/jsonnet-bundler/artifacts.ts
 create mode 100644 lib/manager/jsonnet-bundler/extract.spec.ts
 create mode 100644 lib/manager/jsonnet-bundler/extract.ts
 create mode 100644 lib/manager/jsonnet-bundler/index.ts
 create mode 100644 lib/manager/jsonnet-bundler/readme.md
 create mode 100644 lib/manager/jsonnet-bundler/types.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index ad98b8a588..35580f5723 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -1198,7 +1198,16 @@ With the above config, every PR raised by Renovate will have the label `dependen
 
 This feature can be used to refresh lock files and keep them up-to-date.
 "Maintaining" a lock file means recreating it so that every dependency version within it is updated to the latest.
-Supported lock files are `package-lock.json`, `yarn.lock`, `composer.lock`, `Gemfile.lock`, `poetry.lock` and `Cargo.lock`.
+Supported lock files are:
+
+- `package-lock.json`
+- `yarn.lock`
+- `composer.lock`
+- `Gemfile.lock`
+- `poetry.lock`
+- `Cargo.lock`
+- `jsonnetfile.lock.json`
+
 Others may be added via feature request.
 
 This feature is disabled by default.
diff --git a/lib/manager/api.ts b/lib/manager/api.ts
index 1731c2500f..de7265d558 100644
--- a/lib/manager/api.ts
+++ b/lib/manager/api.ts
@@ -34,6 +34,7 @@ import * as helmv3 from './helmv3';
 import * as homebrew from './homebrew';
 import * as html from './html';
 import * as jenkins from './jenkins';
+import * as jsonnetBundler from './jsonnet-bundler';
 import * as kubernetes from './kubernetes';
 import * as kustomize from './kustomize';
 import * as leiningen from './leiningen';
@@ -103,6 +104,7 @@ api.set('helmv3', helmv3);
 api.set('homebrew', homebrew);
 api.set('html', html);
 api.set('jenkins', jenkins);
+api.set('jsonnet-bundler', jsonnetBundler);
 api.set('kubernetes', kubernetes);
 api.set('kustomize', kustomize);
 api.set('leiningen', leiningen);
diff --git a/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-local-dependencies.json b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-local-dependencies.json
new file mode 100644
index 0000000000..f96725fc11
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-local-dependencies.json
@@ -0,0 +1,15 @@
+{
+  "version": 1,
+  "dependencies": [
+    {
+      "source": {
+        "local": {
+          "directory": "jsonnet"
+        }
+      },
+      "version": ""
+    }
+  ],
+  "legacyImports": true
+}
+
diff --git a/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-no-dependencies.json b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-no-dependencies.json
new file mode 100644
index 0000000000..4388812ca2
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-no-dependencies.json
@@ -0,0 +1,5 @@
+{
+  "version": 1,
+  "dependencies": [],
+  "legacyImports": true
+}
diff --git a/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-with-name.json b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-with-name.json
new file mode 100644
index 0000000000..1294d50ee6
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile-with-name.json
@@ -0,0 +1,16 @@
+{
+  "version": 1,
+  "dependencies": [
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/prometheus-operator/prometheus-operator",
+          "subdir": "jsonnet/mixin"
+        }
+      },
+      "version": "v0.50.0",
+      "name": "prometheus-operator-mixin"
+    }
+  ],
+  "legacyImports": true
+}
diff --git a/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile.json b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile.json
new file mode 100644
index 0000000000..09fcb11670
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/__fixtures__/jsonnetfile.json
@@ -0,0 +1,24 @@
+{
+  "version": 1,
+  "dependencies": [
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/prometheus-operator/prometheus-operator.git",
+          "subdir": "jsonnet/prometheus-operator"
+        }
+      },
+      "version": "v0.50.0"
+    },
+    {
+      "source": {
+        "git": {
+          "remote": "ssh://git@github.com/prometheus-operator/kube-prometheus.git",
+          "subdir": "jsonnet/kube-prometheus"
+        }
+      },
+      "version": "v0.9.0"
+    }
+  ],
+  "legacyImports": true
+}
diff --git a/lib/manager/jsonnet-bundler/__snapshots__/artifacts.spec.ts.snap b/lib/manager/jsonnet-bundler/__snapshots__/artifacts.spec.ts.snap
new file mode 100644
index 0000000000..79d70146d3
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/__snapshots__/artifacts.spec.ts.snap
@@ -0,0 +1,129 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`manager/jsonnet-bundler/artifacts performs lock file maintenance 1`] = `
+Array [
+  Object {
+    "file": Object {
+      "contents": "Updated jsonnetfile.lock.json",
+      "name": "jsonnetfile.lock.json",
+    },
+  },
+]
+`;
+
+exports[`manager/jsonnet-bundler/artifacts performs lock file maintenance 2`] = `
+Array [
+  Object {
+    "cmd": "jb update",
+    "options": Object {
+      "cwd": "/tmp/github/some/repo",
+      "encoding": "utf-8",
+      "env": Object {
+        "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,
+    },
+  },
+]
+`;
+
+exports[`manager/jsonnet-bundler/artifacts returns error when jb update fails 1`] = `
+Array [
+  Object {
+    "artifactError": Object {
+      "lockFile": "jsonnetfile.lock.json",
+      "stderr": "jb released the magic smoke",
+    },
+  },
+]
+`;
+
+exports[`manager/jsonnet-bundler/artifacts returns error when jb update fails 2`] = `
+Array [
+  Object {
+    "cmd": "jb update",
+    "options": Object {
+      "cwd": "/tmp/github/some/repo",
+      "encoding": "utf-8",
+      "env": Object {
+        "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,
+    },
+  },
+]
+`;
+
+exports[`manager/jsonnet-bundler/artifacts returns null if there are no changes 1`] = `Array []`;
+
+exports[`manager/jsonnet-bundler/artifacts updates the vendor dir when dependencies change 1`] = `
+Array [
+  Object {
+    "file": Object {
+      "contents": "Updated jsonnetfile.json",
+      "name": "jsonnetfile.json",
+    },
+  },
+  Object {
+    "file": Object {
+      "contents": "Updated jsonnetfile.lock.json",
+      "name": "jsonnetfile.lock.json",
+    },
+  },
+  Object {
+    "file": Object {
+      "contents": "New foo/main.jsonnet",
+      "name": "vendor/foo/main.jsonnet",
+    },
+  },
+  Object {
+    "file": Object {
+      "contents": "New bar/main.jsonnet",
+      "name": "vendor/bar/main.jsonnet",
+    },
+  },
+  Object {
+    "file": Object {
+      "contents": "vendor/baz/deleted.jsonnet",
+      "name": "|delete|",
+    },
+  },
+]
+`;
+
+exports[`manager/jsonnet-bundler/artifacts updates the vendor dir when dependencies change 2`] = `
+Array [
+  Object {
+    "cmd": "jb update https://github.com/foo/foo.git ssh://git@github.com/foo/foo.git/bar",
+    "options": Object {
+      "cwd": "/tmp/github/some/repo",
+      "encoding": "utf-8",
+      "env": Object {
+        "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/manager/jsonnet-bundler/__snapshots__/extract.spec.ts.snap b/lib/manager/jsonnet-bundler/__snapshots__/extract.spec.ts.snap
new file mode 100644
index 0000000000..2c3bb91b3d
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/__snapshots__/extract.spec.ts.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`manager/jsonnet-bundler/extract extractPackageFile() extracts dependency 1`] = `
+Object {
+  "deps": Array [
+    Object {
+      "currentValue": "v0.50.0",
+      "depName": "prometheus-operator",
+      "lookupName": "https://github.com/prometheus-operator/prometheus-operator.git",
+      "managerData": Object {
+        "subdir": "jsonnet/prometheus-operator",
+      },
+    },
+    Object {
+      "currentValue": "v0.9.0",
+      "depName": "kube-prometheus",
+      "lookupName": "ssh://git@github.com/prometheus-operator/kube-prometheus.git",
+      "managerData": Object {
+        "subdir": "jsonnet/kube-prometheus",
+      },
+    },
+  ],
+}
+`;
+
+exports[`manager/jsonnet-bundler/extract extractPackageFile() extracts dependency with custom name 1`] = `
+Object {
+  "deps": Array [
+    Object {
+      "currentValue": "v0.50.0",
+      "depName": "prometheus-operator-mixin",
+      "lookupName": "https://github.com/prometheus-operator/prometheus-operator",
+      "managerData": Object {
+        "subdir": "jsonnet/mixin",
+      },
+    },
+  ],
+}
+`;
diff --git a/lib/manager/jsonnet-bundler/artifacts.spec.ts b/lib/manager/jsonnet-bundler/artifacts.spec.ts
new file mode 100644
index 0000000000..f8f339fa0d
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/artifacts.spec.ts
@@ -0,0 +1,196 @@
+import { join } from 'upath';
+import { envMock, exec, mockExecAll } from '../../../test/exec-util';
+import { env, fs, git } from '../../../test/util';
+import { setGlobalConfig } from '../../config/global';
+import type { RepoGlobalConfig } from '../../config/types';
+import type { StatusResult } from '../../util/git';
+import type { UpdateArtifactsConfig } from '../types';
+import { updateArtifacts } from '.';
+
+jest.mock('child_process');
+jest.mock('../../util/exec/env');
+jest.mock('../../util/fs');
+jest.mock('../../util/git');
+
+const adminConfig: RepoGlobalConfig = {
+  // `join` fixes Windows CI
+  localDir: join('/tmp/github/some/repo'),
+  cacheDir: join('/tmp/renovate/cache'),
+};
+const config: UpdateArtifactsConfig = {};
+
+describe('manager/jsonnet-bundler/artifacts', () => {
+  beforeEach(() => {
+    env.getChildProcessEnv.mockReturnValue(envMock.basic);
+
+    setGlobalConfig(adminConfig);
+  });
+
+  it('returns null if jsonnetfile.lock does not exist', async () => {
+    fs.readLocalFile.mockResolvedValueOnce(null);
+    expect(
+      await updateArtifacts({
+        packageFileName: 'jsonnetfile.json',
+        updatedDeps: [],
+        newPackageFileContent: '',
+        config,
+      })
+    ).toBeNull();
+  });
+
+  it('returns null if there are no changes', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('Current jsonnetfile.lock.json');
+    const execSnapshots = mockExecAll(exec);
+    git.getRepoStatus.mockResolvedValueOnce({
+      modified: [],
+      not_added: [],
+      deleted: [],
+      isClean(): boolean {
+        return true;
+      },
+    } as StatusResult);
+    expect(
+      await updateArtifacts({
+        packageFileName: 'jsonnetfile.json',
+        updatedDeps: [],
+        newPackageFileContent: '',
+        config,
+      })
+    ).toBeNull();
+    expect(execSnapshots).toMatchSnapshot();
+  });
+
+  it('updates the vendor dir when dependencies change', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('Current jsonnetfile.lock.json');
+    const execSnapshots = mockExecAll(exec);
+    git.getRepoStatus.mockResolvedValueOnce({
+      not_added: ['vendor/foo/main.jsonnet', 'vendor/bar/main.jsonnet'],
+      modified: ['jsonnetfile.json', 'jsonnetfile.lock.json'],
+      deleted: ['vendor/baz/deleted.jsonnet'],
+      isClean(): boolean {
+        return false;
+      },
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('Updated jsonnetfile.json');
+    fs.readLocalFile.mockResolvedValueOnce('Updated jsonnetfile.lock.json');
+    fs.readLocalFile.mockResolvedValueOnce('New foo/main.jsonnet');
+    fs.readLocalFile.mockResolvedValueOnce('New bar/main.jsonnet');
+    expect(
+      await updateArtifacts({
+        packageFileName: 'jsonnetfile.json',
+        updatedDeps: [
+          {
+            depName: 'foo',
+            lookupName: 'https://github.com/foo/foo.git',
+          },
+          {
+            depName: 'foo',
+            lookupName: 'ssh://git@github.com/foo/foo.git',
+            managerData: {
+              subdir: 'bar',
+            },
+          },
+        ],
+        newPackageFileContent: 'Updated jsonnetfile.json',
+        config,
+      })
+    ).toMatchSnapshot([
+      {
+        file: {
+          name: 'jsonnetfile.json',
+          contents: 'Updated jsonnetfile.json',
+        },
+      },
+      {
+        file: {
+          name: 'jsonnetfile.lock.json',
+          contents: 'Updated jsonnetfile.lock.json',
+        },
+      },
+      {
+        file: {
+          name: 'vendor/foo/main.jsonnet',
+          contents: 'New foo/main.jsonnet',
+        },
+      },
+      {
+        file: {
+          name: 'vendor/bar/main.jsonnet',
+          contents: 'New bar/main.jsonnet',
+        },
+      },
+      {
+        file: {
+          name: '|delete|',
+          contents: 'vendor/baz/deleted.jsonnet',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchSnapshot();
+  });
+
+  it('performs lock file maintenance', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('Current jsonnetfile.lock.json');
+    const execSnapshots = mockExecAll(exec);
+    git.getRepoStatus.mockResolvedValueOnce({
+      modified: ['jsonnetfile.lock.json'],
+      isClean(): boolean {
+        return false;
+      },
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('Updated jsonnetfile.lock.json');
+    expect(
+      await updateArtifacts({
+        packageFileName: 'jsonnetfile.json',
+        updatedDeps: [],
+        newPackageFileContent: '',
+        config: {
+          ...config,
+          isLockFileMaintenance: true,
+        },
+      })
+    ).toMatchSnapshot([
+      {
+        file: {
+          name: 'jsonnetfile.lock.json',
+          contents: 'Updated jsonnetfile.lock.json',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchSnapshot();
+  });
+
+  it('returns error when jb update fails', async () => {
+    const execError = new Error();
+    (execError as any).stderr = 'jb released the magic smoke';
+
+    fs.readLocalFile.mockResolvedValueOnce('Current jsonnetfile.lock.json');
+    const execSnapshots = mockExecAll(exec, execError);
+    git.getRepoStatus.mockResolvedValueOnce({
+      modified: ['jsonnetfile.lock.json'],
+      isClean(): boolean {
+        return false;
+      },
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('Updated jsonnetfile.lock.json');
+    expect(
+      await updateArtifacts({
+        packageFileName: 'jsonnetfile.json',
+        updatedDeps: [],
+        newPackageFileContent: '',
+        config: {
+          ...config,
+          isLockFileMaintenance: true,
+        },
+      })
+    ).toMatchSnapshot([
+      {
+        artifactError: {
+          lockFile: 'jsonnetfile.lock.json',
+          stderr: 'jb released the magic smoke',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchSnapshot();
+  });
+});
diff --git a/lib/manager/jsonnet-bundler/artifacts.ts b/lib/manager/jsonnet-bundler/artifacts.ts
new file mode 100644
index 0000000000..5e3bc76056
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/artifacts.ts
@@ -0,0 +1,110 @@
+import { quote } from 'shlex';
+import { TEMPORARY_ERROR } from '../../constants/error-messages';
+import { logger } from '../../logger';
+import { ExecOptions, exec } from '../../util/exec';
+import type { ToolConstraint } from '../../util/exec/types';
+import { readLocalFile } from '../../util/fs';
+import { getRepoStatus } from '../../util/git';
+import { regEx } from '../../util/regex';
+import type {
+  PackageDependency,
+  UpdateArtifact,
+  UpdateArtifactsResult,
+} from '../types';
+
+function dependencyUrl(dep: PackageDependency): string {
+  const url = dep.lookupName;
+  if (dep.managerData?.subdir) {
+    return url.concat('/', dep.managerData.subdir);
+  }
+  return url;
+}
+
+export async function updateArtifacts(
+  updateArtifact: UpdateArtifact
+): Promise<UpdateArtifactsResult[] | null> {
+  const { packageFileName, updatedDeps, config } = updateArtifact;
+  logger.trace({ packageFileName }, 'jsonnet-bundler.updateArtifacts()');
+
+  const lockFileName = packageFileName.replace(regEx(/\.json$/), '.lock.json');
+  const existingLockFileContent = await readLocalFile(lockFileName, 'utf8');
+
+  if (!existingLockFileContent) {
+    logger.debug('No jsonnetfile.lock.json found');
+    return null;
+  }
+
+  const jsonnetBundlerToolConstraint: ToolConstraint = {
+    toolName: 'jb',
+    constraint: config.constraints?.jb,
+  };
+
+  const execOptions: ExecOptions = {
+    cwdFile: packageFileName,
+    docker: {
+      image: 'sidecar',
+    },
+    toolConstraints: [jsonnetBundlerToolConstraint],
+  };
+
+  try {
+    if (config.isLockFileMaintenance) {
+      await exec('jb update', execOptions);
+    } else {
+      const dependencyUrls = updatedDeps.map(dependencyUrl);
+      if (dependencyUrls.length > 0) {
+        await exec(
+          `jb update ${dependencyUrls.map(quote).join(' ')}`,
+          execOptions
+        );
+      }
+    }
+
+    const status = await getRepoStatus();
+
+    if (status.isClean()) {
+      return null;
+    }
+
+    const res: UpdateArtifactsResult[] = [];
+
+    for (const f of status.modified ?? []) {
+      res.push({
+        file: {
+          name: f,
+          contents: await readLocalFile(f),
+        },
+      });
+    }
+    for (const f of status.not_added ?? []) {
+      res.push({
+        file: {
+          name: f,
+          contents: await readLocalFile(f),
+        },
+      });
+    }
+    for (const f of status.deleted ?? []) {
+      res.push({
+        file: {
+          name: '|delete|',
+          contents: f,
+        },
+      });
+    }
+
+    return res;
+  } catch (err) /* istanbul ignore next */ {
+    if (err.message === TEMPORARY_ERROR) {
+      throw err;
+    }
+    return [
+      {
+        artifactError: {
+          lockFile: lockFileName,
+          stderr: err.stderr,
+        },
+      },
+    ];
+  }
+}
diff --git a/lib/manager/jsonnet-bundler/extract.spec.ts b/lib/manager/jsonnet-bundler/extract.spec.ts
new file mode 100644
index 0000000000..7264679f2e
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/extract.spec.ts
@@ -0,0 +1,68 @@
+import { loadFixture } from '../../../test/util';
+import { extractPackageFile } from '.';
+
+const jsonnetfile = loadFixture('jsonnetfile.json');
+const jsonnetfileWithName = loadFixture('jsonnetfile-with-name.json');
+const jsonnetfileNoDependencies = loadFixture(
+  'jsonnetfile-no-dependencies.json'
+);
+const jsonnetfileLocalDependencies = loadFixture(
+  'jsonnetfile-local-dependencies.json'
+);
+
+describe('manager/jsonnet-bundler/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns null for invalid jsonnetfile', () => {
+      expect(
+        extractPackageFile('this is not a jsonnetfile', 'jsonnetfile.json')
+      ).toBeNull();
+    });
+    it('returns null for jsonnetfile with no dependencies', () => {
+      expect(
+        extractPackageFile(jsonnetfileNoDependencies, 'jsonnetfile.json')
+      ).toBeNull();
+    });
+    it('returns null for local dependencies', () => {
+      expect(
+        extractPackageFile(jsonnetfileLocalDependencies, 'jsonnetfile.json')
+      ).toBeNull();
+    });
+    it('returns null for vendored dependencies', () => {
+      expect(
+        extractPackageFile(jsonnetfile, 'vendor/jsonnetfile.json')
+      ).toBeNull();
+    });
+    it('extracts dependency', () => {
+      const res = extractPackageFile(jsonnetfile, 'jsonnetfile.json');
+      expect(res).toMatchSnapshot({
+        deps: [
+          {
+            depName: 'prometheus-operator',
+            lookupName:
+              'https://github.com/prometheus-operator/prometheus-operator.git',
+            currentValue: 'v0.50.0',
+          },
+          {
+            depName: 'kube-prometheus',
+            lookupName:
+              'ssh://git@github.com/prometheus-operator/kube-prometheus.git',
+            currentValue: 'v0.9.0',
+          },
+        ],
+      });
+    });
+    it('extracts dependency with custom name', () => {
+      const res = extractPackageFile(jsonnetfileWithName, 'jsonnetfile.json');
+      expect(res).toMatchSnapshot({
+        deps: [
+          {
+            depName: 'prometheus-operator-mixin',
+            lookupName:
+              'https://github.com/prometheus-operator/prometheus-operator',
+            currentValue: 'v0.50.0',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/lib/manager/jsonnet-bundler/extract.ts b/lib/manager/jsonnet-bundler/extract.ts
new file mode 100644
index 0000000000..7162f4cd88
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/extract.ts
@@ -0,0 +1,57 @@
+import { logger } from '../../logger';
+import { regEx } from '../../util/regex';
+import type { PackageDependency, PackageFile } from '../types';
+import type { Dependency, JsonnetFile } from './types';
+
+const gitUrl = regEx(
+  /(ssh:\/\/git@|https:\/\/)([\w.]+)\/([\w:/\-~]*)\/(?<depName>[\w:/-]+)(\.git)?/
+);
+
+export function extractPackageFile(
+  content: string,
+  packageFile: string
+): PackageFile | null {
+  logger.trace({ packageFile }, 'jsonnet-bundler.extractPackageFile()');
+
+  if (packageFile.match(/vendor\//)) {
+    return null;
+  }
+
+  const deps: PackageDependency[] = [];
+  let jsonnetFile: JsonnetFile;
+  try {
+    jsonnetFile = JSON.parse(content) as JsonnetFile;
+  } catch (err) {
+    logger.debug({ packageFile }, 'Invalid JSON');
+    return null;
+  }
+
+  for (const dependency of jsonnetFile.dependencies ?? []) {
+    const dep = extractDependency(dependency);
+    if (dep) {
+      deps.push(dep);
+    }
+  }
+
+  if (!deps.length) {
+    return null;
+  }
+
+  return { deps };
+}
+
+function extractDependency(dependency: Dependency): PackageDependency | null {
+  if (!dependency.source.git) {
+    return null;
+  }
+
+  const match = gitUrl.exec(dependency.source.git.remote);
+
+  return {
+    depName:
+      dependency.name || match.groups.depName || dependency.source.git.remote,
+    lookupName: dependency.source.git.remote,
+    currentValue: dependency.version,
+    managerData: { subdir: dependency.source.git.subdir },
+  };
+}
diff --git a/lib/manager/jsonnet-bundler/index.ts b/lib/manager/jsonnet-bundler/index.ts
new file mode 100644
index 0000000000..aa3fd5480c
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/index.ts
@@ -0,0 +1,10 @@
+import { GitTagsDatasource } from '../../datasource/git-tags';
+export { updateArtifacts } from './artifacts';
+export { extractPackageFile } from './extract';
+
+export const supportsLockFileMaintenance = true;
+
+export const defaultConfig = {
+  fileMatch: ['(^|/)jsonnetfile.json$'],
+  datasource: GitTagsDatasource.id,
+};
diff --git a/lib/manager/jsonnet-bundler/readme.md b/lib/manager/jsonnet-bundler/readme.md
new file mode 100644
index 0000000000..7a8d7da520
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/readme.md
@@ -0,0 +1,5 @@
+Extracts dependencies from `jsonnetfile.json` files, updates `jsonnetfile.lock.json` and updates the `vendor` directory.
+
+Supports [lock file maintenance](https://docs.renovatebot.com/configuration-options/#lockfilemaintenance).
+
+This plugin requires `jsonnet-bundler >= v0.4.0` since previous versions don't support updating single dependencies.
diff --git a/lib/manager/jsonnet-bundler/types.ts b/lib/manager/jsonnet-bundler/types.ts
new file mode 100644
index 0000000000..9947e87b4d
--- /dev/null
+++ b/lib/manager/jsonnet-bundler/types.ts
@@ -0,0 +1,20 @@
+// original spec https://github.com/jsonnet-bundler/jsonnet-bundler/tree/master/spec/v1
+
+export interface JsonnetFile {
+  dependencies?: Dependency[];
+}
+
+export interface Dependency {
+  source: Source;
+  version: string;
+  name?: string;
+}
+
+export interface Source {
+  git?: GitSource;
+}
+
+export interface GitSource {
+  remote: string;
+  subdir?: string;
+}
diff --git a/lib/util/exec/buildpack.ts b/lib/util/exec/buildpack.ts
index ae47f44e8c..be9b28b847 100644
--- a/lib/util/exec/buildpack.ts
+++ b/lib/util/exec/buildpack.ts
@@ -3,6 +3,7 @@ import { getPkgReleases } from '../../datasource';
 import { logger } from '../../logger';
 import * as allVersioning from '../../versioning';
 import { id as composerVersioningId } from '../../versioning/composer';
+import { id as semverVersioningId } from '../../versioning/semver';
 import type { ToolConfig, ToolConstraint } from './types';
 
 const allToolConfig: Record<string, ToolConfig> = {
@@ -11,6 +12,11 @@ const allToolConfig: Record<string, ToolConfig> = {
     depName: 'composer/composer',
     versioning: composerVersioningId,
   },
+  jb: {
+    datasource: 'github-releases',
+    depName: 'jsonnet-bundler/jsonnet-bundler',
+    versioning: semverVersioningId,
+  },
 };
 
 export async function resolveConstraint(
-- 
GitLab