Setting Up Bhyve with Ansible

This weekend, I moved the system setup for my virtualization server into Ansible, and I got virtual machines up and running with bhyve. I hit a small bump when combining the two that I wanted to share. I’d also like to rave a bit about my experience trying bhyve. It’s amazing!

For background, I’m migrating this server from sketchy and half-assed to maintainable and well planned out. I’m also migrating it from Ubuntu to FreeBSD, but note that I’m not trying to bash Ubuntu. The badness came from how I set it up, not from Ubuntu.

I had already installed FreeBSD and done some manual setup as a proof of concept. My goal was to automate what I did by recreating it with Ansible and then add the required setup to start running virtual machines with bhyve.

New Machine Setup with Ansible

It was pretty easy to recreate my basic setup for a new machine using Ansible. I created two playbooks.

The first playbook takes a fresh install with just my user, an ansible user, and sshd, and it bootstraps some prerequisites. It logs in, becomes root with su, and uses the raw command module to pkg install python. Without the Python interpreter, running raw commands is all Ansible can do. It then installs sudo, updates sudoers, and locks the root password.

The second playbook copies my authorized SSH keys and sets up a secure sshd config. I got some ideas from Michael W. Lucas’s managing sshd with Ansible blog post. However, note that the ansible syntax he uses is now out of date. Instead of with_items, you need loop.

bhyve Setup with Ansible

After implementing my basic setup in Ansible, I started working on the setup to run virtual machines with bhyve. For this part, I followed the From 0 to Bhyve on FreeBSD 13.1 guide that Jim Salter wrote for Klara Systems.

I implemented the steps in the guide as a new Ansible playbook.

- name: Set up bhyve
  hosts: freebsd
  remote_user: ansible
  become: yes

  tasks:
    # Tasks go here!

For each step in the guide, I added a task. First, install the required packages:

  tasks:
    - name: install bhyve packages
      package:
        name:
          - vm-bhyve
          - bhyve-firmware

Create zfs datasets to hold VMs and templates:

    - name: create bhyve dataset
      community.general.zfs:
        name: zroot/bhyve
        state: present
        extra_zfs_properties:
          recordsize: 64K

    - name: create bhyve templates dataset
      community.general.zfs:
        name: zroot/bhyve/.templates
        state: present

And enable services:

    - name: enable bhyve service in rc.conf
      community.general.sysrc:
        name: vm_enable
        state: present
        value: "YES"

    - name: set bhyve vm path in rc.conf
      community.general.sysrc:
        name: vm_dir
        state: present
        value: "zfs:zroot/bhyve"

    - name: enable virtualization support in loader.conf
      community.general.sysrc:
        name: vmm_load
        state: present
        value: "YES"
        path: /boot/loader.conf

The next step was where I ran into trouble. To enable network access for the VMs, you can use the vm-bhyve utility to set up a virtual switch and attach a network interface. These commands are run just once.

# vm init 
# vm switch create public 
# vm switch add public <your network interface>

That’s easy enough to do in Ansible using the command module, but this approach creates a problem:

    - name: initialize vm-bhyve
      ansible.builtin.command: /usr/local/sbin/vm init

    - name: create virtual switch
      ansible.builtin.command: /usr/local/sbin/vm switch create public

    - name: attach interface to virtual switch
      ansible.builtin.command: 
        cmd: "/usr/local/sbin/vm switch add public {{ ansible_facts['default_ipv4']['interface'] }}"

The commands work just fine the first time. However, each time I change something and re-run the playbook, Ansible does these run-once commands again.

TASK [initialize vm-bhyve] ****************************************************
changed: [vmhost]

TASK [create virtual switch] **************************************************
fatal: [vmhost]: FAILED! => {"changed": true, "cmd": ["/usr/local/sbin/vm", "switch", "create", "public"], "delta": "0:00:00.013700", "end": "2022-07-24 15:57:38.339834", "msg": "non-zero return code", "rc": 1, "start": "2022-07-24 15:57:38.326134", "stderr": "/usr/local/sbin/vm: ERROR: switch public already exists", "stderr_lines": ["/usr/local/sbin/vm: ERROR: switch public already exists"], "stdout": "", "stdout_lines": []}

PLAY RECAP ********************************************************************
vmhost          : ok=7    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

This time, the second command returns an error because the switch is already there. Ansible then halts halfway through with a failure, even though the error is not a problem.

All of the other tasks are declarative. You declare the end state you want, and Ansible either verifies that the machine is already there or does whatever is needed to get it there. However, these commands are imperative. You tell Ansible what steps to take, and it does them exactly. Either approach make sense, but mixing them is really awkward.

I could just log in and run the commands manually, but I didn’t want to give up. Instead, I came up with two potential solutions.

The Hard Way

The first thing I did was to look for an Ansible module to interact with the vm-bhyve utility in a declarative way. I didn’t find one, so, naturally, I decided to write one. I’ve never written an Ansible module before, and I’m not much of a Python developer, but after about six hours I had something that pretty much worked.

My ansible-vmbhyve module supports creating and destroying virtual switches and adding and removing interfaces. If you want to try it or contribute to it, you can find it on GitHub here: neapsix/ansible-vmbhyve.

With this module, I could set up the virtual switch in declarative language without getting errors on subsequent runs:

    - name: create virtual switch and attach interface
      vm-bhyve: /usr/local/sbin/vm init
        switch: public
        state: present
        interfaces: "{{ ansible_facts['default_ipv4']['interface'] }}"

Yay, it works! However, because this module has to be installed manually, it’s no easier than running the commands manually or commenting them out after the first run. Fortunately, there’s another way to fix the issue, at least until my half-baked module is ready for primetime.

The Easy Way

Ansible supports some options for error handling when a task fails. You can choose to ignore errors from a certain task and keep going even if it fails. To do so, add ignore_errors: yes to that task:

    - name: create virtual switch
      ansible.builtin.command: /usr/local/sbin/vm switch create public
      ignore_errors: yes

    - name: attach interface to virtual switch
      ansible.builtin.command: 
        cmd: "/usr/local/sbin/vm switch add public {{ ansible_facts['default_ipv4']['interface'] }}"
      ignore_errors: yes

I didn’t love that option, though. I do want the playbook to fail if there’s an error the first time. To make that happen, you can change what Ansible considers a failure. I set the failed_when option to consider the task a failure if the command returns an error, just not this particular error.

    - name: create virtual switch
      ansible.builtin.command: /usr/local/sbin/vm switch create public
      register: result
      failed_when: >
        (result.stderr != '') and
        ("ERROR: switch public already exists" not in result.stderr)        

    - name: attach interface to virtual switch
      ansible.builtin.command: 
        cmd: "/usr/local/sbin/vm switch add public {{ ansible_facts['default_ipv4']['interface'] }}"
      register: result
      failed_when: >
        (result.stderr != '') and
        ("ERROR: failed to add member igb0 to the virtual switch public" not in result.stderr)        

Ansible now passes these tasks with a result of changed: [vmhost].

One caveat: the vm switch add command gives only a generic “failed to add” message instead of a specific “already exists” message. I assume it returns the same error when it fails for any reason, so there’s no benefit to defining failed_when over ignore_errors for this command.

Conclusion and Plug

When I finished this setup, I created a Ubuntu 22.04 VM to test it out, and I was shocked at how easy it was. Right out of the box, my VM had networking, internet access, and its own IP address. It could even run Docker containers. I expected to have to mess around with NAT and bridging, but vm-bhyve did it all for me.

If you use VMs and you haven’t used bhyve, I highly recommend you try it. It’s gained a ton of features since I first looked at it a few years ago, and vm-bhyve makes setting up VMs quick and comfy.

I’m so excited and grateful to have these tools on FreeBSD, and I can’t wait to do more with them!