Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 21: Talking to Vagrant — The Vagrantfile We Generate

"A Vagrantfile is Ruby. Ruby is fine. Hand-edited Ruby in a 200-line file with three nested loops is not."


Why

Vagrant is the right tool for HomeLab's VM layer. It abstracts VirtualBox, Hyper-V, Parallels, and libvirt behind a single command (vagrant up), it has a sensible lifecycle (up, halt, destroy, ssh), it understands Vagrant Cloud and self-hosted box registries, and it has a healthy ecosystem of providers and provisioners.

The Vagrantfile, however, is Ruby. Ruby is a perfectly fine language. Ruby in a homelab Vagrantfile, after six months of accreted edits, is a 200-line monster with three nested loops, two case statements over vm.box, and a comment that says # DO NOT TOUCH — Adam. Every team I have ever met has this Vagrantfile. Every team I have ever met has scarred Adam.

The thesis of this part is: HomeLab does not write a Vagrantfile by hand. It writes a vagrant.yaml data file, and a single, fixed Vagrantfile reads that data file and configures Vagrant accordingly. The Vagrantfile is generated once, committed once, and never edited again. The data file is the user surface. This pattern already exists as Vos in the FrenchExDev monorepo, and HomeLab consumes it directly.


The shape

The Vagrantfile (the one HomeLab generates, ever) is short:

# -*- mode: ruby -*-
# vi: set ft=ruby :
# Generated by HomeLab. Do not edit. Run `homelab vos init` to regenerate.

require 'yaml'

config_path = File.expand_path('config-vos.yaml', __dir__)
local_path  = File.expand_path('local/config-vos-local.yaml', __dir__)

raise "config-vos.yaml not found" unless File.exist?(config_path)

data = YAML.load_file(config_path)
if File.exist?(local_path)
  local = YAML.load_file(local_path)
  data = data.merge(local) { |_, a, b| b.is_a?(Hash) && a.is_a?(Hash) ? a.merge(b) : b }
end

Vagrant.configure("2") do |config|
  data.fetch('machines', []).each do |m|
    config.vm.define m['name'] do |vm|
      vm.vm.box = m['box']
      vm.vm.box_version = m['box_version'] if m['box_version']
      vm.vm.hostname = m['hostname'] if m['hostname']

      (m['networks'] || []).each do |n|
        case n['type']
        when 'private_network'
          vm.vm.network 'private_network', ip: n['ip']
        when 'public_network'
          vm.vm.network 'public_network', bridge: n['bridge']
        when 'forwarded_port'
          vm.vm.network 'forwarded_port', guest: n['guest'], host: n['host']
        end
      end

      (m['synced_folders'] || []).each do |f|
        vm.vm.synced_folder f['host'], f['guest'], type: f['type'] || 'rsync'
      end

      vm.vm.provider m['provider'] || 'virtualbox' do |p|
        p.cpus = m['cpus']   || 2
        p.memory = m['memory'] || 2048
      end

      (m['provisioners'] || []).each do |pr|
        case pr['type']
        when 'shell'
          vm.vm.provision 'shell', path: pr['path']
        when 'file'
          vm.vm.provision 'file', source: pr['source'], destination: pr['destination']
        end
      end
    end
  end
end

That Vagrantfile is fixed. It does not change between projects, between teams, between machines. It reads a YAML file. It applies the YAML. That is all.

The interesting file is config-vos.yaml:

# config-vos.yaml — generated by `homelab vos init` from config-homelab.yaml
machines:
  - name: gateway
    box: frenchexdev/alpine-3.21-dockerhost
    hostname: gateway.devlab
    cpus: 2
    memory: 1024
    provider: virtualbox
    networks:
      - type: private_network
        ip: 192.168.56.10
    synced_folders:
      - host: ./data/certs
        guest: /etc/ssl/devlab
        type: rsync
    provisioners:
      - type: shell
        path: provisioning/enable-docker-tcp.sh

  - name: platform
    box: frenchexdev/alpine-3.21-dockerhost
    hostname: gitlab.devlab
    cpus: 4
    memory: 8192
    provider: virtualbox
    networks:
      - type: private_network
        ip: 192.168.56.11
    provisioners:
      - type: shell
        path: provisioning/enable-docker-tcp.sh

  - name: data
    box: frenchexdev/alpine-3.21-dockerhost
    hostname: data.devlab
    cpus: 2
    memory: 4096
    provider: virtualbox
    networks:
      - type: private_network
        ip: 192.168.56.12

  - name: obs
    box: frenchexdev/alpine-3.21-dockerhost
    hostname: obs.devlab
    cpus: 2
    memory: 2048
    provider: virtualbox
    networks:
      - type: private_network
        ip: 192.168.56.13

That YAML is the multi-VM topology of DevLab from Part 30. HomeLab generates it from the typed HomeLabConfig. The user never edits it. The user edits config-homelab.yaml, runs homelab vos init, and the YAML is regenerated.


The Vos backend

Vos already exists in the FrenchExDev monorepo. It exposes 28 typed Vagrant commands as IVosBackend:

public interface IVosBackend
{
    Task<Result<VagrantUpOutput>> UpAsync(string? machineName = null, CancellationToken ct = default);
    Task<Result<VagrantHaltOutput>> HaltAsync(string? machineName = null, bool force = false, CancellationToken ct = default);
    Task<Result<VagrantDestroyOutput>> DestroyAsync(string? machineName = null, bool force = false, CancellationToken ct = default);
    Task<Result<VagrantStatusOutput>> StatusAsync(string? machineName = null, CancellationToken ct = default);
    Task<Result<VagrantSshOutput>> SshAsync(string machineName, CancellationToken ct = default);
    Task<Result<VagrantSshCommandOutput>> SshCommandAsync(string machineName, string command, CancellationToken ct = default);
    Task<Result<VagrantBoxListOutput>> BoxListAsync(CancellationToken ct = default);
    Task<Result<VagrantBoxAddOutput>> BoxAddAsync(string name, string boxFile, CancellationToken ct = default);
    Task<Result<VagrantBoxRemoveOutput>> BoxRemoveAsync(string name, CancellationToken ct = default);
    Task<Result<VagrantReloadOutput>> ReloadAsync(string? machineName = null, CancellationToken ct = default);
    Task<Result<VagrantProvisionOutput>> ProvisionAsync(string? machineName = null, CancellationToken ct = default);
    // ... and 17 more
}

It is itself a [BinaryWrapper("vagrant")] partial class. HomeLab consumes it directly — there is no second wrapper. The IVosOrchestrator (also from Vos) sits on top, manages the data file merging, and exposes higher-level operations like await orchestrator.UpAllAsync().


VM lifecycle as a state machine

The VM lifecycle has well-defined transitions. We model them with FrenchExDev.Net.FiniteStateMachine (from Part 11):

public enum VmState { NotCreated, Building, Up, Halted, Saved, Destroyed }
public enum VmEvent { Build, Boot, Halt, Save, Resume, Destroy, Crashed }

[StateMachine(typeof(VmState), typeof(VmEvent))]
public partial class VmLifecycleMachine
{
    [Transition(From = VmState.NotCreated, On = VmEvent.Build,   To = VmState.Building)]
    [Transition(From = VmState.Building,   On = VmEvent.Boot,    To = VmState.Up)]
    [Transition(From = VmState.Up,         On = VmEvent.Halt,    To = VmState.Halted)]
    [Transition(From = VmState.Halted,     On = VmEvent.Boot,    To = VmState.Up)]
    [Transition(From = VmState.Up,         On = VmEvent.Save,    To = VmState.Saved)]
    [Transition(From = VmState.Saved,      On = VmEvent.Resume,  To = VmState.Up)]
    [Transition(From = VmState.Up,         On = VmEvent.Destroy, To = VmState.Destroyed)]
    [Transition(From = VmState.Halted,     On = VmEvent.Destroy, To = VmState.Destroyed)]
    [Transition(From = VmState.Saved,      On = VmEvent.Destroy, To = VmState.Destroyed)]
    [Transition(From = VmState.Up,         On = VmEvent.Crashed, To = VmState.NotCreated)]
    public partial void Configure();
}

The state machine source generator emits the implementation, plus a Mermaid diagram for documentation. HomeLab uses it to validate transitions before running them: if the user runs homelab vos halt on a VM that is NotCreated, the state machine refuses the transition with a clear error, before vagrant halt would have been called.


The wiring

[Injectable(ServiceLifetime.Scoped)]
public sealed class VosUpRequestHandler : IRequestHandler<VosUpRequest, Result<VosUpResponse>>
{
    private readonly IVosOrchestrator _orchestrator;
    private readonly IHomeLabEventBus _events;
    private readonly IClock _clock;

    public async Task<Result<VosUpResponse>> HandleAsync(VosUpRequest req, CancellationToken ct)
    {
        await _events.PublishAsync(new VosUpStarted(req.MachineName ?? "all", _clock.UtcNow), ct);
        var sw = Stopwatch.StartNew();

        var result = req.MachineName is { } name
            ? await _orchestrator.UpAsync(name, ct)
            : await _orchestrator.UpAllAsync(ct);

        sw.Stop();

        if (result.IsFailure) return result.Map<VosUpResponse>();

        await _events.PublishAsync(new VosUpCompleted(req.MachineName ?? "all", sw.Elapsed, _clock.UtcNow), ct);
        return Result.Success(new VosUpResponse(MachinesUp: result.Value.Count));
    }
}

The handler is one method. The orchestrator does the work. The wrapper does the binary call. The state machine validates. The event bus reports.


The test

[Fact]
public async Task vos_up_with_no_arg_brings_up_all_machines()
{
    var orchestrator = new FakeVosOrchestrator();
    orchestrator.OnUpAll(() => Result.Success(new UpAllResult(Count: 4)));
    var bus = new RecordingEventBus();
    var handler = new VosUpRequestHandler(orchestrator, bus, new FakeClock(DateTimeOffset.UtcNow));

    var result = await handler.HandleAsync(new VosUpRequest(MachineName: null), CancellationToken.None);

    result.IsSuccess.Should().BeTrue();
    result.Value.MachinesUp.Should().Be(4);
    bus.Recorded.Should().Contain(e => e is VosUpStarted);
    bus.Recorded.Should().Contain(e => e is VosUpCompleted);
}

[Fact]
public void vm_lifecycle_machine_rejects_invalid_transition()
{
    var machine = new VmLifecycleMachine();
    machine.Configure();
    machine.Fire(VmEvent.Build);    // NotCreated → Building
    machine.Fire(VmEvent.Boot);     // Building → Up
    machine.Fire(VmEvent.Halt);     // Up → Halted

    Action invalid = () => machine.Fire(VmEvent.Build);  // Halted/Build is undefined
    invalid.Should().Throw<InvalidTransitionException>();
}

What this gives you that bash doesn't

A hand-written Vagrantfile is the median piece of evidence in the "I have a homelab" pile from Part 01. It is Ruby in a single file. It has no tests. It has comments like # Adam knows why.

A generated, fixed, data-driven Vagrantfile + Vos backend gives you, for the same surface area:

  • A Vagrantfile that never changes (committed once, never edited)
  • A YAML data file generated from typed C# config
  • A 28-command typed backend for every Vagrant operation
  • State-machine validation that rejects invalid transitions before they reach Vagrant
  • Event publication for every meaningful step
  • Tests that exercise the orchestrator without spawning Vagrant

The bargain pays back the first time you regenerate the topology and Vos correctly tears down the old VMs and brings up the new ones — without anyone editing Ruby.


⬇ Download