The Reactor Core

Where the engineers hang out

13 - Setting up Vagrant with Fish and Rust

Published 2023-03-06

Today I decided I wanted to learn some Vagrant, following Amos' post about Setting up a local Ubuntu Server VM on fasterthanlime. Definitely go read that first if you haven't. Cool bear's final hot tip was that Vagrant does all the things automatically instead of manually.

So I fired up my windows desktop and dug in.

Differences

I took a different path because I like taking different paths. I chose not to use Ubuntu because I'm a big fan of Fedora. While researching for my last devcon talk (slides here), I found out that Linus Torvalds himself uses Fedora. If it's good enough for the creator of Linux, it's good enough for me.

I also wanted to set up the Friendly Interactive Shell and the rust programming language, both of which proved challenging in their own right.

Getting Vagrant and VirtualBox

Getting vagrant was as easy as scoop install vagrant. I'm sure your package manager of choice has it too. Getting virtualbox required going to the website. Scoop didn't have it that I could find. But it was a quick install, and even has a dark mode now!

Getting a Fedora VM set up

From 0 to VM was also pretty easy. Searching fedora on the boxes page gave me some very old versions, so I changed my search to fedora 37 and landed on generic/fedora37.

With that in hand, I made a new folder in my side projects called vagrant and ran vagrant init. That created a Vagrantfile, which is a ruby file that configures how Vagrant works. I left everything default except changing config.vm.box to the fedora image I found. Then I ran vagrant up and it created the VM. Super easy.

Minor SSH issues

The output of vagrant up suggested that the VM had an SSH server running on an internal port 22 mapped to my host's port 2222 for 127.0.0.1. So I ran the obvious SSH command ssh vagrant@127.0.0.1:2222 and it didn't work. I tried again from a WSL bash, in case it was something up with windows' version. Nope. Neither worked. I went back and read the docs, and it turns out you have to do vagrant ssh instead. A bit magical, but the magic works, so I can't complain.

Provisioning fish

Fish is a difficult shell to get, unfortunately. Many distros don't include it in the available packages in their package manager. None that I've ever seen install it by default. Fortunately, Fedora at least has it available in dnf, so that's an easy win. At the bottom of the Vagrantfile is a commented section with config.vm.provision. If you uncomment it, it has an apt shell script inline. Changing the apt to dnf lets us update and install things. I have it installing fish:

  config.vm.provision "shell", inline: <<-SHELL
    dnf update -y
    dnf install -y fish
  SHELL

To run this script, you type vagrant provision, so I did. And the output looked good. But fish was not the default shell. The fish docs suggest using these two commands, so I tried them:

echo /usr/local/bin/fish | sudo tee -a /etc/shells
chsh -s /usr/local/bin/fish

The problem is... both are wrong. dnf installs fish to /usr/bin/fish, not /usr/local/bin/fish. And chsh does not exist on this system. I went down quite a rabbit hole trying to figure out why. Supposedly it's provided by the linux-util package, which was already installed by default. But which chsh doesn't turn up any binary for it. Eventually I learned that you can do the same thing (a bit more verbosely) like this: usermod --shell /usr/bin/fish vagrant. The vagrant user is important here, because the provisioning script is run as root, but you ssh as the vagrant user. With that we get a fish prompt after vagrant ssh. Success!

We also want to ensure that we don't keep appending to /etc/shells on every provision, so I added one of my favorite tricks: check or do. Here we check if fish is already in /etc/shells or add it if not. That makes our provision command now:

  config.vm.provision "shell", inline: <<-SHELL
    dnf update -y
    dnf install -y fish

    grep fish /etc/shells || echo /usr/bin/fish | sudo tee -a /etc/shells
    usermod --shell /usr/bin/fish vagrant
  SHELL

Provisioning Rust

This was also tricky, though for different reasons. dnf has rust among its packages, but I've had bad luck managing programming languages with package managers. You often end up needing more than one version, like when node-gyp wants python 2, or you're upgrading major/minor versions.

Fortunately rust provides rustup, which handles all of this for us... or does it? I added the command to my provisioning script... and it bombed out because it was run non-interactively. Fortunately it also gave out a link to the book which helped me get the command right:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y

With that in place, I just needed to get it into the fish path. Rustup already inserts itself for bash/zsh by default, but doesn't do so for fish. Fortunately, we don't need any crazy path manipulation scripts, since fish includes a handy one: fish_add_path. This command does what you mean in config.fish or interactively. Since I want this to stick for future provisioned shells, let's add it to config.fish:

echo "fish_add_path ~/.cargo/bin" >> /home/vagrant/.config/fish/config.fish

That's a pretty long command, and it gets even longer when you add the check to make sure we don't do this every time:

grep -q "fish_add_path ~/.cargo/bin" /home/vagrant/.config/fish/config.fish || echo "fish_add_path ~/.cargo/bin" >> /home/vagrant/.config/fish/config.fish

That's well over my usual hard limit of 120 characters per line, so let's take advantage of the fact we're in Ruby and interpolate that long path. This gets us to our final provisioning script:

  fish_config_file = '/home/vagrant/.config/fish/config.fish'

  # Enable provisioning with a shell script. Additional provisioners such as
  # Ansible, Chef, Docker, Puppet and Salt are also available. Please see the
  # documentation for more information about their specific syntax and use.
  config.vm.provision "shell", inline: <<-SHELL
    dnf update -y
    dnf install -y fish

    grep fish /etc/shells || echo /usr/bin/fish | sudo tee -a /etc/shells
    usermod --shell /usr/bin/fish vagrant

    which cargo || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
    grep -q "fish_add_path ~/.cargo/bin" #{fish_config_file} || echo "fish_add_path ~/.cargo/bin" >> #{fish_config_file}
  SHELL

VS Code highlights this poorly because it's trying too hard. It treats the SHELL heredoc as shell script and thus it thinks that the # starts a comment. Oh well. You can't win them all.

Conclusion

It was a bit trickier than I'd hoped to get everything set up just the way I wanted. If I'd wanted to mix tools, I could have written a dockerfile to do all this much more quickly (or it's possible someone else already has). But I'm happy with my fish and rust setup, and I look forward to seeing how well Vagrant works for some real development in the future.