Forrest Jacobs

Shell-agnostic config

I love fish, and I have built up my fish config over time:

~/.config/fish/config.fish
# Set helix as the editor if it's available.
if type -q hx
  set -gx EDITOR hx
end

set -gx MANOPT --no-justification # Badly justified text is terrible

abbr S 'sudo systemctl'

# etc...

I’ve started working in environments where it’s infeasible to switch the shell from bash, but I still want to use my config. I could make a parallel .bashrc with the equivalent commands, but that’s bound to get out of date. Instead, I turned my fish config into a bash script that prints out the correct instructions for either bash or fish (a la brew shellenv [shell] or zoxide init [shell]). It looks like this:

init_shell.sh
#!/usr/bin/env bash

# init_shell: Prints instructions to configure the given shell.
# Add `eval "$(init_shell bash)"` to .bashrc to set up bash
# or `init_shell fish | source` to config.fish to set up fish.

shell=$1

# Escapes and prints the passed in string.
escape () {
  local text=$1
  text=${text//\\/\\\\}
  text=${text//\'/\\\'}
  echo "'$text'"
}

# Prints a command that sets an environment variable in $shell.
# Pass in the variable name, then the value.
p_export () {
  export "$1"="$2"
  if [[ "${shell}" = "fish" ]]; then
    echo "set -xg $(escape "$1") $(escape "$2")"
  else
    echo "export $(escape "$1")=$(escape "$2")"
  fi
}

# Prints a command that sets an abbreviation in $shell.
# Pass in the abbreviation name, then the full command.
p_abbr () {
  if [[ "${shell}" = "fish" ]]; then
    echo "abbr $(escape "$1") $(escape "$2")"
  else
    echo "alias $(escape "$1")=$(escape "$2")"
  fi
}

# Set helix as the editor if it's available.
if command -v hx &> /dev/null; then
  p_export EDITOR hx
fi

p_export MANOPT --no-justification # Badly justified text is terrible

p_abbr S 'sudo systemctl'

# etc...

Admittedly, this design introduces quite a bit of complexity. For example, you’ll notice the p_export function exports the environment variable in the script before printing out the export command. This is to ensure that later commands in this script can access the environment variable the same way the shell would. You can imagine how easily the command’s internal state could drift from the shell’s state in subtle, difficult to debug ways.

But this additional complexity is worth it for me. My other options are to abandon or duplicate my config (yuck), or switch to bash everywhere (double-yuck). This design has been working well for me for a few weeks.

Rust to Go and back

I wrote two Discord bots relatively recently: a bot called systemctl-bot that lets you start and stop systemd units, and a bot called pipe-bot that posts piped messages. I attempted to write both in Rust, and while one was a delight to write, the other I ended up rewriting in Go in a fit of frustration. Here are a few reasons why:

Starting off too complex

pipe-bot, the program I successfully wrote in Rust, is very simple — it listens to standard in, then calls to the Discord API based on the message:

looSpendMeMsessasgaegeStaWSUraPtptiaadtrtaDsutifesesocrsusotptrsddadtiatdntucieslnientElsLeogError
A diagram showing the flow of execution for pipe-bot. After starting a Discord client, it waits for stdin, parses it, and then either sends a message, updates the Discord status, or logs an error based on the parsed message.

However, I started with systemctl-bot, which monitors and controls systemd units, parses and shares a config file, reads async streams, and generally has weird edge cases. While it’s not overly complex, it’s a lot to get your head around when you’re also learning the borrower checker and async Rust.

StaFUtUepuntdsicathtleosuotnDpaiittsuscs'orusdptdasatttuaesteussStaRrePtgairDssitesecrCcooLorcmondomgfmaicmnegladINrinsoredlonsourtonpiCtomimnancdoCnaPfloilsgYt?essyrsetseumlcttsl
A diagram showing the more complex flow of execution for systemctl-bot. It parses a config file, starts a Discord client, and then branches off into two threads: one handling status updates, and the other handling commands (after registering them with the Discord client.) The status thread waits for unit status updates, then fetches all units' statuses and updates Discord. The command loop waits for a user command, calls systemctl with the appropriate arguments (provided that the targeted unit is in the config file) and then posts the results. If the unit was not in the config file it logs an error instead.

Async Rust

I anticipated fighting with the borrower checker, but—oh boy!—it pales in comparison to writing and understanding async Rust. Since I was coming from the world of “””enterprise software”””, I was used to writing with a level of indirection to facilitate code reuse, unit testing, and refactoring. However, Rust makes you pay for indirection that involves tracking more state or more complex state since it has to track that state while the async call is in progress. Watch this video to hear someone much smarter than me explain why the current state of async Rust ain’t quite it yet:

Catching up with async Rust by fasterthanlime on YouTube
Thumbnail for Catching up with async Rust

Testing

Something possessed me to go full enterprise software sicko mode during the development of systemctl-bot and unit test every module to as close to 100% coverage as possible. I’m glad I did because it taught me more about generics and about Box, Rc, and Arc as I tried to find ways to mock dependencies, but it also taught me that this style of testing in Rust produces a huge glob of code that is painful to wrangle.

I decided to take a different approach while developing pipe-bot: I just mocked the outer edges of my program and let every test be an integration test. Any unit-level errors that mattered seem to come up in these tests, and since my program was small it wasn’t difficult to identify the specific function where the error originated. I got 99% of the benefit of unit testing with 20% of the effort.

Final thoughts

I enjoy Rust, but I respect Go. Rust is more fun to write, and the compiler’s strict checking is a superpower that ensures you don’t screw yourself up too badly. However, async Rust is a huge pain for me, and while Go is boring, sometimes it’s the ticket to complete a project.

Keeping NixOS systems up to date with GitHub Actions

Keeping my NixOS servers up to date was dead simple before I switched to flakes – I enabled system.autoUpgrade, and I was good to go. Trying the same with a shared flakes-based config introduced a few problems:

  1. I configured autoUpgrade to commit flake lock changes, but it ran as root. This created file permission issues since my user owned my NixOS config.
  2. Even when committing worked, each machine piled up slightly different commits waiting for me to upstream.

I could have fixed issue #1 by changing the owner, but fixing #2 required me to rethink the process. Instead of having each individual machine update their lock file, I realized it would be cleaner to update the lock file upstream first, and then rebuild each server from upstream. Updating the lock file first ensures there’s only one version of history, and that makes it easier to reason about what is installed on each server.

Below is one method of updating the shared lock file before updating each server:

Updating flake.lock with GitHub Actions

The update-flake-lock GitHub Action updates your project’s flake lock file on a schedule. It essentially runs nix flake update --commit-lock-file and then opens a pull request. Add it to your NixOS config repository like this:

/.github/workflows/main.yml
name: update-dependencies
on:
  workflow_dispatch: # allows manual triggering
  schedule:
    - cron: '0 6 * * *' # daily at 1 am EST/2 am EDT

jobs:
  update-dependencies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: DeterminateSystems/nix-installer-action@v12
      - id: update
        uses: DeterminateSystems/update-flake-lock@v23

Add this step if you want to automatically merge the pull request:

      - name: Merge
        run:
          gh pr merge --auto "${{ steps.update.outputs.pull-request-number }}" --rebase
        env:
          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
        if: ${{ steps.update.outputs.pull-request-number != '' }}

Pulling changes & rebuilding

Next, it’s time to configure NixOS to pull changes and rebuild. The configuration below adds two systemd services:

  • pull-updates pulls config changes from upstream daily at 4:40. It has a few guardrails: it ensures the local repository is on the main branch, and it only permits fast-forward merges. You’ll want to set serviceConfig.User to the user owning the repository. If it succeeds, it kicks off rebuild
  • rebuild rebuilds and switches to the new configuration, and reboots if required. It’s a simplified version of autoUpgrade’s script.
NixOS Config
systemd.services.pull-updates = {
  description = "Pulls changes to system config";
  restartIfChanged = false;
  onSuccess = [ "rebuild.service" ];
  startAt = "04:40";
  path = [pkgs.git pkgs.openssh];
  script = ''
    test "$(git branch --show-current)" = "main"
    git pull --ff-only
  '';
  serviceConfig = {
    WorkingDirectory = "/etc/nixos";
    User = "user-that-owns-the-repo";
    Type = "oneshot";
  };
};

systemd.services.rebuild = {
  description = "Rebuilds and activates system config";
  restartIfChanged = false;
  path = [pkgs.nixos-rebuild pkgs.systemd];
  script = ''
    nixos-rebuild boot
    booted="$(readlink /run/booted-system/{initrd,kernel,kernel-modules})"
    built="$(readlink /nix/var/nix/profiles/system/{initrd,kernel,kernel-modules})"

    if [ "''${booted}" = "''${built}" ]; then
      nixos-rebuild switch
    else
      reboot now
    fi
  '';
  serviceConfig.Type = "oneshot";
};

There are many possible variations. For example, in my real config I split the pull service into separate fetch and merge services so I can fetch more frequently. You could also replace the GitHub action with a different scheduled script, or change the rebuild service to never (or always!) reboot.