A few months ago my work wife Aldo Borrero created Ethereum.nix. Since then, we’ve both been gradually adding packages and services, and it serves as a playground of sorts for us both to learn and improve our understanding of Nix and NixOS.

Which brings me to the title of today’s blog post, best explained through an example of what I was doing this week.

Process config

One of our goals with Ethereum.nix is to make it easy to spin up various pieces of Ethereum related infrastructure. So naturally we have started defining NixOS modules which expose certain arguments as NixOS options, help with opening firewall ports and so on.

Sadly we have discovered that, whilst all the processes we’re dealing with can accept config files, in practice those config files tend to be poorly documented, or use their formats in unusual ways that mean it’s not easy to generate said config from Nix attributes.

So the decision was made to leverage the process args themselves as they tend to be better documented (--help) and more stable.

This means we have to generate said arguments from a Nix attribute set, and in the beginning this was done manually:

script = ''
  ${cfg.package}/bin/geth \
  --nousb \
  --ipcdisable \
  ${optionalString (cfg.network != null) ''--${cfg.network}''} \
  --syncmode ${cfg.syncmode} \
  --gcmode ${cfg.gcmode} \
  --port ${toString cfg.port} \
  --maxpeers ${toString cfg.maxpeers} \
  ${
    if cfg.http.enable
    then ''--http --http.addr ${cfg.http.address} --http.port ${toString cfg.http.port}''
    else ""
  } \
  ${optionalString (cfg.http.apis != null) ''--http.api ${lib.concatStringsSep "," cfg.http.apis}''} \
  ${
    if cfg.websocket.enable
    then ''--ws --ws.addr ${cfg.websocket.address} --ws.port ${toString cfg.websocket.port}''
    else ""
  } \
  ${optionalString (cfg.websocket.apis != null) ''--ws.api ${lib.concatStringsSep "," cfg.websocket.apis}''} \
  ${optionalString cfg.metrics.enable ''--metrics --metrics.addr ${cfg.metrics.address} --metrics.port ${toString cfg.metrics.port}''} \
  --authrpc.addr ${cfg.authrpc.address} --authrpc.port ${toString cfg.authrpc.port} --authrpc.vhosts ${lib.concatStringsSep "," cfg.authrpc.vhosts} \
  ${
    if (cfg.authrpc.jwtsecret != "")
    then ''--authrpc.jwtsecret ${cfg.authrpc.jwtsecret}''
    else ''--authrpc.jwtsecret ${stateDir}/jwtsecret''
  } \
  ${lib.escapeShellArgs cfg.extraArgs} \
  --datadir ${dataDir}
'';

Version two was a bit better, introducing some simple functions for constructing the arg strings, but it was still quite verbose. I was never quite happy with it, and always felt there was a better way.

So this week I set out to find that better way.

Options

If I’m gonna spend the time to define module options that describe what the config for my module looks like, then why can’t I use that same specification to generate the process args?

options = {
  enable = mkEnableOption (mdDoc "Go Ethereum Node");

  args = {
    datadir = mkOption {
      type = types.nullOr types.path;
      default = null;
      description = mdDoc "Data directory to use for storing Geth state";
    };

    port = mkOption {
      type = types.port;
      default = 30303;
      description = mdDoc "Port number Go Ethereum will be listening on, both TCP and UDP.";
    };

    http = {
      enable = mkEnableOption (mdDoc "Go Ethereum HTTP API");

      addr = mkOption {
        type = types.str;
        default = "127.0.0.1";
        description = mdDoc "HTTP-RPC server listening interface";
      };

      port = mkOption {
        type = types.port;
        default = 8545;
        description = mdDoc "Port number of Go Ethereum HTTP API.";
      };

      ...
    };
    ...
  };
};

What I needed was a function that could take an attribute set of options, and it’s corresponding config attribute set, and spit out the various flags I would need to pass:

  defaultArgReducer = value:
    if (isList value)
    then concatStringsSep "," value
    else toString value;

  defaultPathReducer = path: let
    arg = concatStringsSep "-" path;
  in "--${arg}";

  dotPathReducer = path: let
    arg = concatStringsSep "." path;
  in "--${arg}";

  mkFlag = {
    path,
    opt,
    args,
    argReducer ? defaultArgReducer,
    pathReducer ? defaultPathReducer,
  }: let
    value = attrByPath path opt.default args;
    hasValue = (hasAttrByPath path args) && value != null;
    hasDefault = (hasAttrByPath ["default"] opt) && value != null;
  in
    assert assertMsg (isOption opt) "opt must be an option";
      if (hasValue || hasDefault)
      then let
        arg = pathReducer path;
      in
        if (opt.type == types.bool && value)
        then "${arg}"
        else "${arg} ${argReducer value}"
      else "";

  mkFlags = {
    opts,
    args,
    pathReducer ? defaultPathReducer,
  }:
    collect (v: (isString v) && v != "") (
      mapAttrsRecursiveCond
      (as: !isOption as)
      (path: opt: mkFlag {inherit path opt args pathReducer;})
      opts
    );

Now I’m not going to get into the specifics of how this all works, that’s not the point of this blog post. What I do want to talk about is what it took to develop and refine these 44 lines of code.

Yolo

As I talked about last week in Nix and NixOS: a retrospective, Nix is not a general purpose language (some people disagree), and it was never intended to be. I’ve often heard Nix described as JSON but with functions.

So what does that mean when you want to develop and test something new?

Well, you don’t have a lot to work with.

There is unit testing, but it’s pretty unpleasant to use. Breakpoints for debugging are only a recent introduction. And logging is rudimentary at best.

Most of the time, much like writing a bash script, you make your change and run it.

In my particular case, I had a local checkout of ethereum.nix, which I referred to from a separate flake:

inputs = {
    ethereum = {
        url = "path:/home/brian/Development/github.com/nix-community/ethereum.nix";
    };
};

I would make changes in ethereum.nix, run nix flake lock --update-input ethereum, then build my test system configuration. More often than not I would get an error, and based on the trace I would go back to ethereum.nix, make some changes, and repeat the process all over again.

Eventually my test system would build without errors. At that point I would fire up the REPL to see what the eventual script argument would look like. If it didn’t look right, I went back to ethereum.nix, made some changes, and… well you get the idea.

A snail crawling across a road

Speeding things up… a bit

Nix has a REPL, and after some time spent going back and forth between ethereum.nix and my test project, I grew tired of the slow pace and decided to fire up the REPL and see if I could improve things.

❯ nix repl
Welcome to Nix 2.12.0. Type :? for help.

nix-repl>

First things first, we need to load ethereum.nix’s flake, and then use the inputs to get an instance of nixpkgs and lib:

nix-repl> :lf .
Added 18 variables.

nix-repl> pkgs = inputs.nixpkgs.legacyPackages.x86_64-linux

nix-repl> lib = inputs.nixpkgs.lib

Next we need to import the library function I’m trying to develop:

nix-repl> optLib = import ./modules/lib.nix { inherit pkgs lib; }

And now we need some sample options and args:

nix-repl> opts = { foo.bar = lib.options.mkOption({ type = lib.types.bool; }); foo.baz = lib.options.mkOption({ type = lib.types.nullOr lib.types.str; default = "hello world"; }); }

nix-repl> args = { foo.bar = true; foo.baz = "slow"; }

I can finally test my function with:

nix-repl> optLib.flags.mkFlags { inherit opts args; }
[ "--foo-bar" "--foo-baz slow" ]

Et voila! But, you don’t think that worked first time, did you? 😂

Even worse, whenever you make a change you can’t just run optLib = import ./modules/lib.nix { inherit pkgs lib; } again to bring in your changes. Whilst it executes fine, it just does not reflect the changes made in the underlying file. That’s because import in Nix is memoized.

You have to restart the REPL, or run :r to reload, before it will bring in any changes. But that resets the environment and that means having to setup the lib and pkgs variables, cfg and args etc AGAIN!

Not much better is it?

A man with his head in his hands

How you should use the REPL

I asked zimbatm to review this blog post, just to be sure there wasn’t a trick I had missed with the REPL. And lo and behold, there is a better way.

You can specify a file when loading the REPL, which is automatically evaluated and the resulting attributes made available in the REPL environment. This means we can create a test file like this:

rec {
  pkgs = import <nixpkgs> {};
  lib = import <nixpkgs/lib>;

  opts = {
    foo.bar = lib.options.mkOption {type = lib.types.bool;};
    foo.baz = lib.options.mkOption {
      type = lib.types.nullOr lib.types.str;
      default = "hello world";
    };
  };
  args = {
    foo.bar = true;
    foo.baz = "slow";
  };

  flags = (import ./modules/lib.nix {inherit pkgs lib;}).flags;

  out = flags.mkFlags {inherit opts args;};
}

We then load the REPL with nix repl --file test.nix and check the out attribute:

❯ nix repl --file test.nix
Welcome to Nix 2.12.0. Type :? for help.

Loading installable ''...
Added 6 variables.
nix-repl> out
[ "--foo-bar" "--foo-baz slow" ]

nix-repl>

If we make changes, :r can be used to reload the file and execute everything fresh!

This is an incredibly useful pattern, and one that I feel should feature somewhere prominently in the docs. I can see it saving me a lot of time in the future, and it’s something I’ll be sure to point out to anyone new to Nix.

Summary

The first version of this summary went something like:

“Developing Nix is just slow, get used to it. I hope it gets better.”.

But instead, writing this post has highlighted for me one of the main reasons I do this.

By forcing myself to state what I know, in the open, I’m making it clear what I don’t know or what I may have missed, to people who are lot more knowledgeable and experienced with Nix than I am.

And because I’m not an idiot, and I ask some of those people to review these posts beforehand, I get to learn when I’ve missed a trick or misunderstood a concept. And if anything manages to fall through the gaps…

An XKCD comic with a guy at his computer needing to correct someone wrong on the internet