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