Nix in Braiins

Nix is a powerful package manager for Linux and other Unix systems that makes package management reliable and reproducible. It provides a declarative approach to package and configuration management and is designed to ensure that package installations are isolated from each other. This isolation prevents the common "dependency hell" and makes it easy to roll back changes. Nix stores all packages in unique directories in the Nix store, identified by hashes of their dependencies, ensuring that different configurations can coexist without interference. Its approach to package management is highly innovative and offers a high degree of flexibility, particularly for complex software environments.

Nix and Rust together work pretty well to create a powerful combination for reliable and efficient software development.

In Braiins, we use Nix in several places:

Keep in mind that you maybe do not have access to all of these repositories.

Why Nix for Rust?

Rust is known for its safety and performance, while Nix offers unmatched reproducibility and dependency management. The integration of Nix in Rust development can make some things a breeze

  • Reproducibility: Nix ensures that your Rust projects are built in a consistent environment, avoiding the "it works on my machine" problem.
  • Dependency Management: Nix handles complex dependency trees gracefully, ensuring that all dependencies are correctly versioned and isolated.
  • Isolation: Using Nix, each project can have its own isolated environment with specific versions of the Rust compiler and dependencies.

Our main motivation were the following:

  • Effective caching in CI - Nix hashes everything and keeps built derivations in a so called "nix store" typically located in the /nix/store folder of your local installation
  • Dependencies for cross-compilation - In Tooling, we cross-compile to several different architectures for Linux and also produce builds of Toolbox for Windows and Mac

Example: Setting up Nix

To start using Rust with Nix, you first need to set up an environment.

Start by installing Nix, here's how to do it:

  1. Install Nix: If you haven't already installed Nix, you can do so by running:

    sh <(curl -L https://nixos.org/nix/install) --daemon
    
  2. Enable Flake Support: As of Nix 2.4, flakes are an experimental feature, so you need to enable them manually. Edit /etc/nix/nix.conf (or ~/.config/nix/nix.conf if you're not using NixOS) and add:

    experimental-features = nix-command flakes
    

Here's an example of how to create a simple Nix shell environment for Rust development:

  1. Create a file named shell.nix with the following content:

    { pkgs ? import <nixpkgs> {} }:
    
    pkgs.mkShell {
      buildInputs = [ pkgs.rustc pkgs.cargo ];
    }
    

    This shell.nix file specifies that you want an environment with rustc (the Rust compiler) and cargo (Rust's package manager and build tool).

  2. Enter the Nix shell by running:

    nix-shell
    

    This command will download and install the specified versions of rustc and cargo into a temporary environment. Note that while I show you how to do this with rustc and Cargo, there is a plenty of packages you can find here https://search.nixos.org/packages

  3. Once inside the Nix shell, you can start using Rust as usual. Any Rust project you build inside this shell will use the exact versions of Rust and Cargo specified in shell.nix.

In our repositories, however, we use a more finetuned approach - managing Rust versions with fenix and building Rust crates with crane.

We will introduce these properly after we get into flakes.

Example: Adding Rustfmt and Clippy

Extend your shell.nix to include rustfmt and clippy:

{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [ pkgs.rustc pkgs.cargo pkgs.rustfmt pkgs.clippy ];
}

Now, when you enter the Nix shell, you'll also have access to rustfmt and clippy alongside rustc and cargo.

Introduction to Flakes

Flakes are a feature in Nix that provides a more reproducible and manageable way of handling packages and configurations. A flake is essentially a function that has inputs and outputs, and locks dependencies to a specific version.

What are Flakes?

Flakes are a new way to manage Nix projects that bring several advantages over the traditional nix-shell approach. They allow for:

  • Reproducible Environments: Flakes lock the versions of all dependencies, ensuring that every user gets the exact same development environment.
  • Declarative Configuration: Flakes make Nix configurations more readable and manageable by using a declarative syntax.
  • Improved Dependency Management: Flakes handle dependencies in a more consistent and reliable manner.

Key Features of Flakes

The key features of flakes include:

  • Lock File: Flakes generate a flake.lock file which pins the versions of all dependencies, making builds reproducible across different machines.
  • Inputs: Flakes can specify inputs (like Nix packages or other flakes) which are tracked and versioned.
  • Outputs: Flakes produce outputs, such as Nix packages, NixOS modules, or anything else that can be built with Nix.

Flakes in the Context of Rust Development

In Rust development, flakes can be particularly useful for:

  • Managing Rust Toolchain: Specifying the exact versions of rustc and cargo ensures that all developers and CI systems use the same compiler and tool versions.
  • Handling Dependencies: Rust projects often have numerous dependencies; flakes can manage these dependencies in a reproducible manner.

Example: Creating a Basic Flake

  1. Start by creating a flake.nix file in your Rust project's root directory:

    {
      description = "A simple Nix flake example";
    
      inputs = {
        nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
      };
    
      outputs = { self, nixpkgs }: {
        defaultPackage.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.hello;
      };
    }
    

    This file defines a basic flake which re-exports the hello package from the nixpkgs repository. If you try nix run .#, it should print "Hello world!".

  2. Build your project with:

    nix build
    

    This command only builds the flake.

Here is an example with a custom derivation for the default package:

{
  description = "A simple Nix flake with a custom derivation";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
  };

  outputs = { self, nixpkgs }: {
    defaultPackage.x86_64-linux = nixpkgs.stdenv.mkDerivation {
      name = "my-custom-package";
      buildCommand = ''
        mkdir -p $out/bin
        echo -e "#!/bin/sh\necho Hello from custom derivation!" > $out/bin/my-script.sh
        chmod +x $out/bin/my-script.sh
      '';
    };
  };
}

Using flakes in this way ensures that your Rust project is built in a consistent, reproducible environment, minimizing "works on my machine" issues.

Configuring the Rust Development Environment

We can see how to work with Rust by taking inspiration from the docker-spider project:

#![allow(unused)]
fn main() {
{
  inputs = {
    crane = {
      url = "github:ipetkov/crane";
      inputs = {
        flake-utils.follows = "flake-utils";
        nixpkgs.follows = "nixpkgs";
      };
    };
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    flake-utils.url = "github:numtide/flake-utils";
    nixpkgs.url = "nixpkgs/nixos-unstable";
  };

  outputs = { self, crane, fenix, flake-utils, nixpkgs }:
    flake-utils.lib.eachDefaultSystem (system: {
      packages.default =
        let
          craneLib = crane.lib.${system}.overrideToolchain
            fenix.packages.${system}.minimal.toolchain;
        in

        craneLib.buildPackage {
          src = ./.;
          pname = "docker-spider";
        };
    });
}
}

First, we provide toolchain via Fenix (this example is simple and just takes the default minimal toolchain, but we can use it to get toolchain info from sources such as the rust-toolchain.toml file as well).

Next, we create a simple crane package:

craneLib.buildPackage {
  src = ./.;
  pname = "docker-spider";
};

Entering the Development Shell: Use the nix develop command to enter a shell with all the dependencies specified in your flake.nix:

nix develop

Inside this shell, you will have access to the Rust compiler (rustc), Cargo, and any other dependencies you've added to your flake.nix.

Building and Running the Rust Project with Nix Flakes

To build and run your Rust project using Nix flakes, follow these steps:

  1. Building with Nix: Use the nix build command to build your project according to the specifications in your flake.nix:

    nix build
    
  2. Running the Built Executable: The output of your build will be in the result directory. You can run your executable directly from there:

    ./result/bin/my-rust-project
    

That's about it for the introduction.