Ansible in Nix

I bought a new VPS amid the deluge of LowendTalk’s Black Friday promotions, and spent some time to hone my DevOps skills. My favorite IaC is terraform, unfortunately there exist no mature provider for the bare metal linux boxes. My second choice was SaltStack thanks to its declarative states, though the foreplay was a little tedious. I would like something lightweight, so I tried Ansible.

Ansible is agentless, which means the minimum requirement is simply a ssh connection, with superuser privilege.I could bootstrap the machine to my desired state:

with the following command:

ansible-playbook -i inventory.ini bootstrap.yml -u root -k

The -u root instructed ansible to use root account to connect, and prompt the password challenge thanks to the -k argument. Once the service account is setup, they are no longer required.

The rabbit hole

The real challenge emerged from the wireguard setup. Due to the complexity of wireguard setup, I used the role provided by lablabs.wireguard. First, install the collection via ansbile-galaxy:

ansible-galaxy collection install lablabs.wireguard

Then craft a new playbook, wireguard.yml like this:

- name: Deploy WireGuard VPN using the lablabs.wireguard collection
  hosts: wireguard_servers
  become: true

  roles:
    - lablabs.wireguard.wireguard

Additionally, define the wireguard_server in the inventory.yml, and our configuration in group_vars/wireguard_servers.yml. Then we could run:

ansible-playbook -i inventory.yml wireguard.yml
... ...
TASK [lablabs.wireguard.wireguard : Create clients configs] ********************
fatal: [example.com]: FAILED! => {"msg": "You need to install \"jmespath\" prior to running json_query filter"}

Checking out the code here, it seemed that the jinja filter json_query depended on an absent package jmespath.

Install jmespath via python3Packages

My first attempt is to add jmespath to the python packages like:

{
  home.packages = with pkgs.python313Packages; [
    python
    ipython
    ... ...
    jmespath
    uv
  ];
}

Same error, also confirmed that the package was NOT discoverable at all!

 python -c "import jmespath"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    import jmespath
ModuleNotFoundError: No module named 'jmespath'

I understood that the nix packages were installed in an isolated subdirectory of /nix/store, but I would expect the packages installed via this approach would be integrated into a single environment. Clearly I am wrong.

But how do the python apps, for example ansible-playbook, look for their dependencies then? Just out of curiosity, I peeked the implementation of ansible-playbook:

 head -n 7  /nix/store/gnls4kcmy9fr5a3x7kqrsqwq2m96rrsg-python3.12-ansible-core-2.18.6/bin/ansible-playbook
#! /nix/store/8ivrpmp3arvxbr6imdwm2d28q9cjsqvi-bash-5.2p37/bin/bash -e
PATH=${PATH:+':'$PATH':'}
PATH=${PATH/':''/nix/store/ljpcaw5flk6pp75dr0jnb9pyfc2pydmq-python3.12-yangson-1.6.2/bin'':'/':'}
PATH='/nix/store/ljpcaw5flk6pp75dr0jnb9pyfc2pydmq-python3.12-yangson-1.6.2/bin'$PATH
PATH=${PATH#':'}
PATH=${PATH%':'}
export PATH

The dependencies were resolved and injected via the PATH environment variables in the nix-specific wrapper! That explained why the newly installed jmespath was never discovered.

We have to build a development environment to resolve the dependency issue.

Install via devenv

I created a devenv configuration with the prompt, Python with ansible and jmespath lib; copied the following snippet to devenv.nix:

{
  pkgs,
  lib,
  config,
  ...
}:
{
  # https://devenv.sh/packages/
  packages = [ pkgs.ansible ];

  # https://devenv.sh/languages/
  languages = {
    python = {
      enable = true;
      venv.enable = true;
      venv.requirements = "jmespath";
    };
  };

  # See full reference at https://devenv.sh/reference/options/
}

and ran devenv shell to install the dependencies. Then I encountered a different error:

TASK [lablabs.wireguard.wireguard : Check if Public Key is valid base64 string] *******************************************************************************************************
[ERROR]: Task failed: Conditional result (True) was derived from value of type 'str' at '/Users/kunxi/.ansible/collections/ansible_collections/lablabs/wireguard/roles/wireguard/tasks/pre_check.yml:6:9'. Conditionals must have a boolean result.

The error was self-explainable: the precheck asserts the publicKey is base64 encoded, but the str value would NOT infer the boolean result. But why we did not see this error before? It turned out the devenv installed a cutting edge ansbile-2.19.3 with python-3.13.8, the latest version was more strict on the type checking.

With pinned version, it took a long time for devenv to build the package from the source. I bailed out and tried the nix-shell.

Install via nix-shell

The shell.nix is pretty straightforward:

{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  # Add buildInputs for packages available directly in Nixpkgs
  buildInputs = [
    (pkgs.python312.withPackages (ps: [
      ps.ansible
      ps.ansible-core
      ps.jmespath
      # Add other Python packages here using ps.<package_name>
    ]))
    # Add other system-level tools if needed, e.g., pkgs.git, pkgs.jq
  ];
}

Viola, everything just works!

Conclusion

The python libraries in the nix are installed just like others, — in their own sandbox. Technically, we should never install them via nixpkgs unless they expose cli entry points, such as uv, ruff, etc. If the library has optional dependencies, it is determined by the nix package maintainer whether to include them; — that is why some packages have foo, fooFull variants.

nix-shell and devenv are designed exactly to address this issue by bundling isolated packages into a cohesive working environment.