From 44d953737d45cdee88e0d88d53b64d57e8a6e065 Mon Sep 17 00:00:00 2001
From: Jamie Magee <jamie.magee@gmail.com>
Date: Fri, 11 Nov 2022 15:45:46 -0800
Subject: [PATCH] feat(manager/nix): add support for nix package manager
 (#18727)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/manager/api.ts                |   2 +
 lib/modules/manager/nix/artifacts.spec.ts | 278 ++++++++++++++++++++++
 lib/modules/manager/nix/artifacts.ts      |  82 +++++++
 lib/modules/manager/nix/extract.spec.ts   |  66 +++++
 lib/modules/manager/nix/extract.ts        |  28 +++
 lib/modules/manager/nix/index.ts          |  15 ++
 lib/modules/manager/nix/readme.md         |   4 +
 7 files changed, 475 insertions(+)
 create mode 100644 lib/modules/manager/nix/artifacts.spec.ts
 create mode 100644 lib/modules/manager/nix/artifacts.ts
 create mode 100644 lib/modules/manager/nix/extract.spec.ts
 create mode 100644 lib/modules/manager/nix/extract.ts
 create mode 100644 lib/modules/manager/nix/index.ts
 create mode 100644 lib/modules/manager/nix/readme.md

diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index 3f1282ed17..810bc94024 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -50,6 +50,7 @@ import * as maven from './maven';
 import * as meteor from './meteor';
 import * as mint from './mint';
 import * as mix from './mix';
+import * as nix from './nix';
 import * as nodenv from './nodenv';
 import * as npm from './npm';
 import * as nuget from './nuget';
@@ -134,6 +135,7 @@ api.set('maven', maven);
 api.set('meteor', meteor);
 api.set('mint', mint);
 api.set('mix', mix);
+api.set('nix', nix);
 api.set('nodenv', nodenv);
 api.set('npm', npm);
 api.set('nuget', nuget);
diff --git a/lib/modules/manager/nix/artifacts.spec.ts b/lib/modules/manager/nix/artifacts.spec.ts
new file mode 100644
index 0000000000..3f8068ca73
--- /dev/null
+++ b/lib/modules/manager/nix/artifacts.spec.ts
@@ -0,0 +1,278 @@
+import type { StatusResult } from 'simple-git';
+import { join } from 'upath';
+import {
+  envMock,
+  mockExecAll,
+  mockExecSequence,
+} from '../../../../test/exec-util';
+import { env, fs, git } from '../../../../test/util';
+import { GlobalConfig } from '../../../config/global';
+import type { RepoGlobalConfig } from '../../../config/types';
+import * as docker from '../../../util/exec/docker';
+import type { UpdateArtifactsConfig } from '../types';
+import { updateArtifacts } from '.';
+
+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'),
+  containerbaseDir: join('/tmp/renovate/cache/containerbase'),
+};
+const dockerAdminConfig = { ...adminConfig, binarySource: 'docker' };
+
+process.env.BUILDPACK = 'true';
+
+const config: UpdateArtifactsConfig = {};
+const lockMaintenanceConfig = { ...config, isLockFileMaintenance: true };
+const updateInputCmd = `nix \
+    --extra-experimental-features nix-command \
+    --extra-experimental-features flakes \
+    flake lock --update-input nixpkgs`;
+const lockfileMaintenanceCmd = `nix \
+    --extra-experimental-features nix-command \
+    --extra-experimental-features flakes \
+    flake update`;
+
+describe('modules/manager/nix/artifacts', () => {
+  beforeEach(() => {
+    jest.resetAllMocks();
+    env.getChildProcessEnv.mockReturnValue({
+      ...envMock.basic,
+      LANG: 'en_US.UTF-8',
+      LC_ALL: 'en_US',
+    });
+    GlobalConfig.set(adminConfig);
+    docker.resetPrefetchedImages();
+  });
+
+  it('returns if no flake.lock found', async () => {
+    const execSnapshots = mockExecAll();
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [],
+      newPackageFileContent: '',
+      config,
+    });
+
+    expect(res).toBeNull();
+    expect(execSnapshots).toEqual([]);
+  });
+
+  it('returns null if unchanged', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('content');
+    const execSnapshots = mockExecAll();
+    git.getRepoStatus.mockResolvedValue({
+      modified: [''],
+    } as StatusResult);
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: 'some new content',
+      config,
+    });
+
+    expect(res).toBeNull();
+    expect(execSnapshots).toMatchObject([{ cmd: updateInputCmd }]);
+  });
+
+  it('returns updated flake.lock', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('current flake.lock');
+    const execSnapshots = mockExecAll();
+    git.getRepoStatus.mockResolvedValue({
+      modified: ['flake.lock'],
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('new flake.lock');
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: 'some new content',
+      config: { ...config, constraints: { python: '3.7' } },
+    });
+
+    expect(res).toEqual([
+      {
+        file: {
+          contents: 'new flake.lock',
+          path: 'flake.lock',
+          type: 'addition',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchObject([{ cmd: updateInputCmd }]);
+  });
+
+  it('supports docker mode', async () => {
+    GlobalConfig.set(dockerAdminConfig);
+    const execSnapshots = mockExecAll();
+    git.getRepoStatus.mockResolvedValue({
+      modified: ['flake.lock'],
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('new flake.lock');
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: '{}',
+      config: { ...config, constraints: { nix: '2.10.0' } },
+    });
+
+    expect(res).toEqual([
+      {
+        file: {
+          path: 'flake.lock',
+          type: 'addition',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchObject([
+      { cmd: 'docker pull renovate/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" ' +
+          '-e BUILDPACK_CACHE_DIR ' +
+          '-e CONTAINERBASE_CACHE_DIR ' +
+          '-w "/tmp/github/some/repo" ' +
+          'renovate/sidecar ' +
+          'bash -l -c "' +
+          'install-tool nix 2.10.0 ' +
+          '&& ' +
+          updateInputCmd +
+          '"',
+      },
+    ]);
+  });
+
+  it('supports install mode', async () => {
+    GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
+    const execSnapshots = mockExecAll();
+    git.getRepoStatus.mockResolvedValue({
+      modified: ['flake.lock'],
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('new flake.lock');
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: '{}',
+      config: { ...config, constraints: { nix: '2.10.0' } },
+    });
+
+    expect(res).toEqual([
+      {
+        file: {
+          path: 'flake.lock',
+          type: 'addition',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchObject([
+      { cmd: 'install-tool nix 2.10.0' },
+      {
+        cmd: updateInputCmd,
+        options: { cwd: '/tmp/github/some/repo' },
+      },
+    ]);
+  });
+
+  it('catches errors', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('current flake.lock');
+    const execSnapshots = mockExecSequence([new Error('exec error')]);
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: '{}',
+      config,
+    });
+
+    expect(res).toEqual([
+      {
+        artifactError: { lockFile: 'flake.lock', stderr: 'exec error' },
+      },
+    ]);
+    expect(execSnapshots).toMatchObject([{ cmd: updateInputCmd }]);
+  });
+
+  it('returns updated flake.lock when doing lockfile maintenance', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('current flake.lock');
+    const execSnapshots = mockExecAll();
+    git.getRepoStatus.mockResolvedValue({
+      modified: ['flake.lock'],
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('new flake.lock');
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: '{}',
+      config: lockMaintenanceConfig,
+    });
+
+    expect(res).toEqual([
+      {
+        file: {
+          contents: 'new flake.lock',
+          path: 'flake.lock',
+          type: 'addition',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchObject([{ cmd: lockfileMaintenanceCmd }]);
+  });
+
+  it('uses nix from config', async () => {
+    GlobalConfig.set(dockerAdminConfig);
+    const execSnapshots = mockExecAll();
+    git.getRepoStatus.mockResolvedValue({
+      modified: ['flake.lock'],
+    } as StatusResult);
+    fs.readLocalFile.mockResolvedValueOnce('new lock');
+
+    const res = await updateArtifacts({
+      packageFileName: 'flake.nix',
+      updatedDeps: [{ depName: 'nixpkgs' }],
+      newPackageFileContent: 'some new content',
+      config: {
+        ...config,
+        constraints: { nix: '2.10.0' },
+      },
+    });
+
+    expect(res).toEqual([
+      {
+        file: {
+          path: 'flake.lock',
+          type: 'addition',
+        },
+      },
+    ]);
+    expect(execSnapshots).toMatchObject([
+      { cmd: 'docker pull renovate/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" ' +
+          '-e BUILDPACK_CACHE_DIR ' +
+          '-e CONTAINERBASE_CACHE_DIR ' +
+          '-w "/tmp/github/some/repo" ' +
+          'renovate/sidecar ' +
+          'bash -l -c "' +
+          'install-tool nix 2.10.0 ' +
+          '&& ' +
+          updateInputCmd +
+          '"',
+      },
+    ]);
+  });
+});
diff --git a/lib/modules/manager/nix/artifacts.ts b/lib/modules/manager/nix/artifacts.ts
new file mode 100644
index 0000000000..44a005b70e
--- /dev/null
+++ b/lib/modules/manager/nix/artifacts.ts
@@ -0,0 +1,82 @@
+import is from '@sindresorhus/is';
+import { quote } from 'shlex';
+import { logger } from '../../../logger';
+import { exec } from '../../../util/exec';
+import type { ExecOptions } from '../../../util/exec/types';
+import { readLocalFile } from '../../../util/fs';
+import { getRepoStatus } from '../../../util/git';
+import { regEx } from '../../../util/regex';
+import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
+
+export async function updateArtifacts({
+  packageFileName,
+  config,
+  updatedDeps,
+}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> {
+  const lockFileName = packageFileName.replace(regEx(/\.nix$/), '.lock');
+  const existingLockFileContent = await readLocalFile(lockFileName, 'utf8');
+  if (!existingLockFileContent) {
+    logger.debug('No flake.lock found');
+    return null;
+  }
+
+  let cmd: string;
+
+  if (config.isLockFileMaintenance) {
+    cmd = `nix \
+    --extra-experimental-features nix-command \
+    --extra-experimental-features flakes \
+    flake update`;
+  } else {
+    const inputs = updatedDeps
+      .map(({ depName }) => depName)
+      .filter(is.nonEmptyStringAndNotWhitespace)
+      .map((depName) => `--update-input ${quote(depName)}`)
+      .join(' ');
+    cmd = `nix \
+    --extra-experimental-features nix-command \
+    --extra-experimental-features flakes \
+    flake lock ${inputs}`;
+  }
+  const execOptions: ExecOptions = {
+    cwdFile: packageFileName,
+    toolConstraints: [
+      {
+        toolName: 'nix',
+        constraint: config.constraints?.nix,
+      },
+    ],
+    docker: {
+      image: 'sidecar',
+    },
+  };
+
+  try {
+    await exec(cmd, execOptions);
+
+    const status = await getRepoStatus();
+    if (!status.modified.includes(lockFileName)) {
+      return null;
+    }
+    logger.debug('Returning updated flake.lock');
+    return [
+      {
+        file: {
+          type: 'addition',
+          path: lockFileName,
+          contents: await readLocalFile(lockFileName),
+        },
+      },
+    ];
+  } catch (err) {
+    logger.warn({ err }, 'Error updating flake.lock');
+    return [
+      {
+        artifactError: {
+          lockFile: lockFileName,
+          stderr: err.message,
+        },
+      },
+    ];
+  }
+}
diff --git a/lib/modules/manager/nix/extract.spec.ts b/lib/modules/manager/nix/extract.spec.ts
new file mode 100644
index 0000000000..e9a88ef8f9
--- /dev/null
+++ b/lib/modules/manager/nix/extract.spec.ts
@@ -0,0 +1,66 @@
+import { GitRefsDatasource } from '../../datasource/git-refs';
+import { id as nixpkgsVersioning } from '../../versioning/nixpkgs';
+import { extractPackageFile } from '.';
+
+describe('modules/manager/nix/extract', () => {
+  it('returns null when no nixpkgs', () => {
+    const content = `{
+  inputs = {};
+}`;
+    const res = extractPackageFile(content);
+
+    expect(res).toBeNull();
+  });
+
+  it('returns nixpkgs', () => {
+    const content = `{
+  inputs = {
+    nixpkgs.url = "github:nixos/nixpkgs/nixos-21.11";
+  };
+}`;
+
+    const res = extractPackageFile(content);
+
+    expect(res?.deps).toEqual([
+      {
+        depName: 'nixpkgs',
+        currentValue: 'nixos-21.11',
+        datasource: GitRefsDatasource.id,
+        packageName: 'https://github.com/NixOS/nixpkgs',
+        versioning: nixpkgsVersioning,
+      },
+    ]);
+  });
+
+  it('is case insensitive', () => {
+    const content = `{
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-21.11";
+  };
+}`;
+
+    const res = extractPackageFile(content);
+
+    expect(res?.deps).toEqual([
+      {
+        depName: 'nixpkgs',
+        currentValue: 'nixos-21.11',
+        datasource: GitRefsDatasource.id,
+        packageName: 'https://github.com/NixOS/nixpkgs',
+        versioning: nixpkgsVersioning,
+      },
+    ]);
+  });
+
+  it('ignores nixpkgs with no explicit ref', () => {
+    const content = `{
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs";
+  };
+}`;
+
+    const res = extractPackageFile(content);
+
+    expect(res).toBeNull();
+  });
+});
diff --git a/lib/modules/manager/nix/extract.ts b/lib/modules/manager/nix/extract.ts
new file mode 100644
index 0000000000..52fc0a354e
--- /dev/null
+++ b/lib/modules/manager/nix/extract.ts
@@ -0,0 +1,28 @@
+import { regEx } from '../../../util/regex';
+import { GitRefsDatasource } from '../../datasource/git-refs';
+import { id as nixpkgsVersioning } from '../../versioning/nixpkgs';
+import type { PackageDependency, PackageFile } from '../types';
+
+const nixpkgsRegex = regEx(/"github:nixos\/nixpkgs\/(?<ref>[a-z0-9-.]+)"/i);
+
+export function extractPackageFile(content: string): PackageFile | null {
+  const deps: PackageDependency[] = [];
+
+  const match = nixpkgsRegex.exec(content);
+  if (match?.groups) {
+    const { ref } = match.groups;
+    deps.push({
+      depName: 'nixpkgs',
+      currentValue: ref,
+      datasource: GitRefsDatasource.id,
+      packageName: 'https://github.com/NixOS/nixpkgs',
+      versioning: nixpkgsVersioning,
+    });
+  }
+
+  if (deps.length) {
+    return { deps };
+  }
+
+  return null;
+}
diff --git a/lib/modules/manager/nix/index.ts b/lib/modules/manager/nix/index.ts
new file mode 100644
index 0000000000..58b43af484
--- /dev/null
+++ b/lib/modules/manager/nix/index.ts
@@ -0,0 +1,15 @@
+import { GitRefsDatasource } from '../../datasource/git-refs';
+
+export { extractPackageFile } from './extract';
+export { updateArtifacts } from './artifacts';
+
+export const supportsLockFileMaintenance = true;
+
+export const defaultConfig = {
+  fileMatch: ['(^|\\/)flake\\.nix$'],
+  commitMessageTopic: 'nixpkgs',
+  commitMessageExtra: 'to {{newValue}}',
+  enabled: false,
+};
+
+export const supportedDatasources = [GitRefsDatasource.id];
diff --git a/lib/modules/manager/nix/readme.md b/lib/modules/manager/nix/readme.md
new file mode 100644
index 0000000000..f2a3539807
--- /dev/null
+++ b/lib/modules/manager/nix/readme.md
@@ -0,0 +1,4 @@
+The [`nix`](https://github.com/NixOS/nix) manager supports:
+
+- [`lockFileMaintenance`](https://docs.renovatebot.com/configuration-options/#lockfilemaintenance) updates for `flake.lock`
+- [nixpkgs](https://github.com/NixOS/nixpkgs) updates
-- 
GitLab