2024.08 Vol.1

// so you want a declarative rust environment #Devprod

I finally sunk into Nix. It took a whole week, but I think it was worth it. I’d been meaning to learn it for years and just bit the bullet. On paper, Nix sounds extremely useful and the declarative-and-deterministic-builds description is a great match for Bitcoin-land.

I started my journey by transferring over a few of my home servers to NixOS. This was a bit of a jump-into-the-deep-end decision since the Nix ecosystem is actually made up of a bunch of tooling (a language, a package manager, a registry, an OS…). You can run any combination of these tools on your existing OS and get some value. But maintaining my home servers was actually causing me some pain, so figured might as well just go for it. I found NixOS easy to get up and going. The Nix ecosystem documentation leaves a ton to be desired, but NixOS itself has a nice declarative interface. You can get a ton out of it without having to fully understand all the little quirks of the Nix language and the layers upon layers of conventions being used under the hood. The one issue which has bitten me a ton already, and I would bet anything will continue to in the future, is the strange relationship Nix configuration files have with git. It boils down to three rules. First, if none of the configuration is in a git repository, all is good. If all the configuration is in a git repository, all is good. But! If any part is in a git repository, the rest of the configuration which is not staged or checked in is completely ignored. This results in tons of “file not found” errors during early iterations. The more I learn about the flakes feature of Nix and its git dependencies, these rules make more sense, but I’ll admit to losing a lot of time on it.

But generally, the servers were easy to get going and my laptop wasn’t too much more work. I haven’t bothered to dive into some extensions like home-manager which can also manage user dotfiles (so beyond just system configuration which is what NixOS focuses on). My configuration output isn’t overly interesting, but took me a few tries, so here is how I have my configuration stored in my dotfiles repository.

.
├── gemini2
│   ├── configuration.nix
│   ├── flake.lock
│   ├── flake.nix
│   └── hardware-configuration.nix
├── gemini3
│   ├── configuration.nix
│   ├── flake.lock
│   ├── flake.nix
│   └── hardware-configuration.nix
├── gemini4
│   ├── configuration.nix
│   ├── flake.lock
│   ├── flake.nix
│   └── hardware-configuration.nix
├── justfile
├── mercury1
│   ├── configuration.nix
│   ├── flake.lock
│   ├── flake.nix
│   └── hardware-configuration.nix
├── mercury4
│   ├── configuration.nix
│   ├── flake.lock
│   ├── flake.nix
│   └── hardware-configuration.nix
├── modules
│   ├── containers.nix
│   ├── development-lite.nix
│   ├── development.nix
│   ├── display-and-windows.nix
│   ├── font.nix
│   ├── keyboard.nix
│   ├── network.nix
│   ├── secrets.nix
│   ├── sound.nix
│   └── user.nix
└── README.md  

The nixos directory in my dotfiles.

Each directory is a host with its configuration, except for modules which contains shared chunks of configuration. The justfile codifies my workflow updating dependencies and configuration.

# Checkout the repository to the admin's home folder.
# Recipies are intended to be run from there, no need to symlink to /etc/nixos.

hostname := `cat /etc/hostname 2>/dev/null || echo "unknown-host"`

# List commands.
list:
    @echo "Commands for {{hostname}}"
    @just --list

# Rebuild and switch to the new configuration.
rebuild:
    @echo "Rebuilding and switching to new configuration..."
    sudo nixos-rebuild switch --flake {{justfile_directory()/hostname}}#{{hostname}}

# Update all flake inputs.
update:
    @echo "Updating flake..."
    nix flake update {{justfile_directory()/hostname}}/

# Add, commit, and push updates to version control.
push MSG="Update nixos configuration":
    @echo "Push {{hostname}} configuration changes to version control..."
    git pull
    git add .
    git commit -m "{{MSG}} [{{hostname}}]"
    git push

# Collect garbage and optimize store.
gc:
    @echo "Collecting garbage and optimizing store..."
    nix-collect-garbage -d
    nix-store --optimize

option OPTION:
    nixos-option -I nixos-config=./{{hostname}}/configuration.nix {{OPTION}}

Just recipes to help me out.

A lot of this reads pretty easy at this level, but what is this flake term being tossed around?

Nix and Flakes

Nix is in a strange spot at the moment. About five years ago a new experimental feature called flakes was introduced. It is not a complete re-write, but a layer on top of all other Nix stuff. With that said, it is a bit of a mental model shift. And it also corresponds with some other big interface changes, like migration from a handful of commands to one command which has subcommands (e.g. nix-env to nix profile etc.). And while flakes are very popular, they are still marked as experimental and (apparently) don’t have 100% community buy-in. A lot of the official documentation makes no mention of them at all, or this new nix command! One can imagine how a newcomer on the scene sees this as total chaos. This tool is going to fix all my problems with deterministic builds, but can’t figure out its own tool set?! But I think I have the lay of the land, and made the call to just roll with all the new stuff. It is all new to me anyways.

My mental model for flakes, and I have little idea how this is implemented under the hood, is that they lock a configuration’s inputs at a relatively granular level. How granular? Well, the old-school alternative to flakes are channels which are kinda pretty much git branches of large monorepos of packages. And these packages are constantly being updated by the maintainers (heroes!). If you have a dependency on a package in a channel and you want to update just that package, you have to update the whole channel, and all your dependencies in that channel are updated. No granular control, it is very centralized.

Flakes on the other hand have an explicit nix flake update command to update dependencies. You can update all or specific flake dependencies. This allows your configuration to be more decentralized and robust.

But other than that, I view flakes as just a wrapper. You can see in my NixOS dotfiles how a flake and its lock file live next to the configuration for each machine. Technically speaking, I could use a single flake to wrap all my hosts configuration, so they share dependencies at all times. That would be a nice world, but adding any complexity to the Nix language quickly breaks my brain, so I am going with simple copy/paste for now.

A Declarative Rust Environment

As I started hacking on some projects in the new environment, I ran into an interesting fork in the road. Should I manage my Rust toolchain with the standard rustup tool or should I go all in on Nix? If you squint your eyes, the tools are accomplishing the same goal: grab a bunch of rust tooling on a certain version and somehow get it on your $PATH. rustup is available as a package in NixOS, so it is not out of the question to just use it. But I felt like having two tools simultaneously attempting to control a view of the PATH was going to end in sadness at some point. rustup is also still imperative, it doesn’t have Nix’s declarative I-see-what-tools-I-am-using-at-a-glance powers. So I decided to continue my all-in adventure into Nix.

For this use case, the tutorials and blogs generally point to adding another tool into the mix: direnv. I have used direnv in the past and its pretty nifty. You hook it up to your shell in your run config file, and every time you change directories it looks for a .envrc file. If it finds one, it loads the configuration into your currently running shell. When you exit the directory, it automatically unloads it. It could be something as simple as setting an environment variable. But direnv actually has tight integration with Nix, so a single command such as use flake tells it to look for a flake and load that into the shell. This is pretty sweet, one can imagine bouncing between Rust project that have different MSRVs and having the toolchain automatically updated. All further tooling on top, like IDEs, “just work”! I adopted this pattern in my little Raiju program, adding a flake for Golang development along with the .envrc hook.

This is great for little projects with a contributor or two, but what about larger projects which have no intention to introduce some flake nonsense to the repository? Can I still use some general flake to setup a toolchain for each project I am working on?

To start, I defined a flake that did pretty much what I want: put a rust toolchain on the PATH. I am not the first person to think of this. And there are quite a few projects such as rust-overlay and crane that can help out here. Some of these, like crane, have a larger scope which includes building and deploying applications. I don’t want to go there yet, since I rely on CI/CD container tooling these days. So I ended up rolling with fenix which is part of the nix-community organization. I was tempted to write it from scratch to learn more Nix stuff, but my head was already really hurting, so saved that for another day.

 {
  # Fenix should handle setting the RUST_SRC_PATH envrionment variable
  # so that rust-analyzer is able to find the tooling. Could look
  # into using fromToolchainFile if helpful.
  description = "Reusable Rust development environment.";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, fenix }:
    let
      system = "x86_64-linux";
      # Adding the fenix overlay to packages 
      # to make it easy to grab their rust-analyzer-nightly.
      pkgs = import nixpkgs {
        inherit system;
        overlays = [ fenix.overlays.default ];
      };

      # Mix and matching across toolchains.
      # "Complete" corresponds to the *complete*
      # profile of the *nightly* channel.
      toolchain = pkgs.fenix.combine [
        pkgs.fenix.stable.cargo
        pkgs.fenix.stable.rustc
        # Rust-src brings the source of std lib, technically
        # not required for compiling, but nice for development. 
        pkgs.fenix.stable.rust-src
        pkgs.fenix.stable.clippy
        pkgs.fenix.complete.rustfmt
      ];
    in
    {
      devShells.${system}.default = pkgs.mkShell {
        # Dependencies needed at build time (vs. runtime buildInputs).
        nativeBuildInputs = with pkgs; [
          toolchain
          # Using the fenix provided package over combining it manually since
          # this appears to iron out some compatability issues using components
          # from different channels.
          rust-analyzer-nightly
        ];
      };
    };
} 

A flake.nix for a Rust toolchain.

If I was starting a new project, I could plop this into ./flake.nix in the repository root, add my .envrc hook, and be good to go. But again, what about an existing project? I first thought that this is where the nix develop command would help me out. develop does load a devShells into a shell, but unlike direnv which extends the existing shell, it is a brand new shell. And it is very tightly sandbox’d, only having the declared inputs. So in this case, it would only have the Rust toolchain. Which doesn’t sound too bad until you try and edit some code and realize you have no editor. I could go ahead and add an editor to my toolchain flake, but this seems at odds with the granular goal of flakes.

Hm. Well, direnv somehow does this, so how does it work under the hood? I haven’t actually checked, but I suspect it uses nix print-dev-env. print-dev-env builds a flake and outputs a shell script which can be sourced to modify an existing shell. Pretty close to what I want. But it is a little clunky, and I imagine I would soon get lost with what has and has not been applied to a shell. Back to the “bouncing between rust projects” example, I might have to just fire up new shells instead of hoping all settings were overwritten each time I switched.

Ideally, I could just add a .envrc file to these existing projects, and then ignore the file locally. Turns out, git totally already has this feature. In the .git directory of a repository, there is a info/exclude file. You can add .gitignore syntax here, but the file isn’t tracked so no noise for the other contributors.

# Direnv config and output.
.envrc
.direnv/
# Nix output.
result  

.git/info/exlude in each repository.

I made a standard exclude file that I symlink in each of my existing projects. I then added a .envrc file to the root (now ignored) which points to my shared Rust toolchain flake.

# Point to rust development flake.
use flake $HOME/.local/share/development/rust/  

.envrc in the repository root.

I now have a toolchain loaded when I enter the directory! Next steps would be to maybe add some fancy-ness to the toolchain flake. For example, detect if a project has a Rust toolchain configuration file and pull the correct toolchain version from there. This is definitely possible with fenix, but again, adding just a touch of Nix breaks my brain, so I might just resort to some copy/paste for now.

The Journey Continues

As mentioned a few times, the Nix language is gnarly and breaks me. But I did push a little further with my Raiju app. Not only does the flake expose a developer shell, it also is wired up to build the executable and expose it in the conventional way. This makes it very easy to add it to a NixOS configuration and is pretty much a one-liner. I went one step further and defined a Systemd service for it. This makes it super easy to declarative-ly enable the service feature of the app in NixOS…and definitely is the limit of my Nix language powers.

 # NixOS module for systemd configuration.
nixosModules.raiju = { config, lib, pkgs, ... }:
let
  # Option validators.
  hostType = lib.types.strMatching "([a-zA-Z0-9.-]+):([0-9]{1,5})";
  twoIntArray = with lib.types; addCheck (listOf int) (x: 
    builtins.length x == 2 && builtins.all (i: builtins.isInt i) x
  );
  threeIntArray = with lib.types; addCheck (listOf int) (x: 
    builtins.length x == 3 && builtins.all (i: builtins.isInt i) x
  );
  # Convert list of ints to comma-separated string.
  intListToString = intList: builtins.concatStringsSep "," (map toString intList);
in
{
  options.services.raiju = {
    enable = lib.mkEnableOption "Raiju Daemon";
    rpcHost = lib.mkOption {
      type = hostType;
      default = "localhost:10009";
      description = "The address and port of the LND instance's RPC interface.";
    };
    macaroonFile = lib.mkOption {
      type = lib.types.path;
      description = "Path to a macaroon of the LND instance with admin priviledges.";
    };
    tlsCertificateFile = lib.mkOption {
      type = lib.types.path;
      description = "Path to the TLS certificate of the LND instance.";
    };
    liquidityFees = lib.mkOption {
      type = threeIntArray;
      default = [1 50 2500];
      description = ''
        The feerates (PPM) raiju applies to channels. The first option is applied to channels with too much local liquidity. 
        The second is for balanced channels (in between the two threshold values). The third option is applied to 
        channels with too little local liquidity.
      '';
    };
    liquidityThresholds = lib.mkOption {
      type = twoIntArray;
      default = [80 20];
      description = ''
        Channel liquidity thresholds defined as the percent of a channel's capacity which is local liquidity. Channels
        which have a local liquidity percent higher than the first value are considered "too much local" and  have the 
        minimum fee applied. Channels with a local liquidity percent in between the two values are considered balanced 
        and have the middle fee applied. Channels with a local liquidity percent below the second value are 
        considered "too little local" and have the maximum fee applied.
      '';
    };
    liquidityStickiness = lib.mkOption {
      type = lib.types.int;
      default = 10;
      description = ''
        The percentage of channel capacity to wait before returning a channel's fee to balanced. For example, if
        a channel goes above the local liquidity maximum threshold, but then sinks back below it, the fees won't
        change back to balanced until it is 10% (the default stickiness setting in this case) below the
        maximum threshold in order to avoid fee configuration thrashing.
      '';
    };
  };

  config = lib.mkIf config.services.raiju.enable {
    systemd.services.raiju = {
      description = "Raiju";
      wantedBy = [ "multi-user.target" ];
      # *Requires* establishes the dependency, while *after* establishes the order.
      # Using the strict requirement of requires so that raiju only starts
      # if LND does. The *wants* dependency is a loose requirement which will still attempt to
      # start raiju even if LND fails, which would be useless.
      requires = [ "lnd.service" ];
      after = [ "lnd.service" ];
      serviceConfig = {
        ExecStart = "${self.packages.${pkgs.system}.default}/bin/raiju daemon";
        Restart = "always";
      };
      environment = {
        RAIJU_HOST = "${config.services.raiju.rpcHost}";
        RAIJU_MAC_PATH = "${config.services.raiju.macaroonFile}";
        RAIJU_TLS_PATH = "${config.services.raiju.tlsCertificateFile}";
        RAIJU_LIQUIDITY_FEES = "${intListToString config.services.raiju.liquidityFees}";
        RAIJU_LIQUIDITY_STICKINESS = "${toString config.services.raiju.liquidityStickiness}";
        RAIJU_LIQUIDITY_THRESHOLDS = "${intListToString config.services.raiju.liquidityThresholds}";
      };
    };

    environment = {
      systemPackages = [ self.packages.${pkgs.system}.default ];
      # Set system variables so raiju command can be called easily.
      variables = {
        RAIJU_HOST = "${config.services.raiju.rpcHost}";
        RAIJU_MAC_PATH = "${config.services.raiju.macaroonFile}";
        RAIJU_TLS_PATH = "${config.services.raiju.tlsCertificateFile}";
        RAIJU_LIQUIDITY_FEES = "${intListToString config.services.raiju.liquidityFees}";
        RAIJU_LIQUIDITY_STICKINESS = "${toString config.services.raiju.liquidityStickiness}";
        RAIJU_LIQUIDITY_THRESHOLDS = "${intListToString config.services.raiju.liquidityThresholds}";
      };
    };
  };
};

Service definition in flake.nix.

Dang, that is a lot of code! The argument validators were definitely pushing it for me. But it is worth it when you see how easy it is to flip this on in a NixOS configuration consumer.

# Add Raiju and enable the daemon.
raiju.nixosModules.default
{
  services.raiju = {
    enable = true;
    rpcHost = "localhost:10009";
    macaroonFile = "/etc/nix-bitcoin-secrets/admin.macaroon";
    tlsCertificateFile = "/etc/nix-bitcoin-secrets/lnd-cert";
    liquidityFees = [1 100 5000];
  };
}

NixOS configuration in the flake modules list which enables the Raiju service.

While Nix is crazy, all in all my system does feel a lot cleaner and more maintainable. We will see where my sanity is at in a few months.