Part 22: Talking to Vagrant — The Data YAML We Read
"The Vagrantfile is the engine. config-vos.yaml is the fuel."
Why
Part 21 introduced the fixed Vagrantfile that reads config-vos.yaml. This part is about that data file: its schema, how it is generated, how it is merged, how it is validated, and why HomeLab regenerates it from config-homelab.yaml instead of letting the user edit it directly.
The reason for the indirection is the same reason the Vagrantfile is fixed: every time a human edits a configuration file, the configuration drifts. config-vos.yaml is the canonical Vagrant input, but the user-facing surface is config-homelab.yaml — higher-level, schema-validated, with intellisense in VSCode. HomeLab projects from one to the other in the Generate stage of the pipeline. The user gets one file to think about. Vagrant gets the file it needs.
The shape
config-vos.yaml has its own JSON Schema, also generated from C# [Builder] types. The schema lives at schemas/vos-config.schema.json. The user does not edit this file in normal use, but homelab vos init writes it explicitly so that VSCode can validate any local override the user puts in local/config-vos-local.yaml.
[Builder]
public sealed record VosConfig
{
[JsonSchemaProperty(Required = true)]
public required IReadOnlyList<VosMachineConfig> Machines { get; init; }
[JsonSchemaProperty(Description = "Default provider for machines that don't specify one")]
[JsonSchemaEnum("virtualbox", "hyperv", "parallels", "libvirt")]
public string DefaultProvider { get; init; } = "virtualbox";
}
[Builder]
public sealed record VosMachineConfig
{
[JsonSchemaProperty(Required = true)]
public required string Name { get; init; }
[JsonSchemaProperty(Required = true)]
public required string Box { get; init; }
[JsonSchemaProperty(Description = "Optional pinned version")]
public string? BoxVersion { get; init; }
[JsonSchemaProperty(Description = "VM hostname")]
public string? Hostname { get; init; }
[JsonSchemaProperty(Minimum = 1, Maximum = 64)]
public int Cpus { get; init; } = 2;
[JsonSchemaProperty(Minimum = 256, Maximum = 131072)]
public int Memory { get; init; } = 2048;
[JsonSchemaEnum("virtualbox", "hyperv", "parallels", "libvirt")]
public string? Provider { get; init; }
public IReadOnlyList<VosNetworkConfig> Networks { get; init; } = Array.Empty<VosNetworkConfig>();
public IReadOnlyList<VosSyncedFolderConfig> SyncedFolders { get; init; } = Array.Empty<VosSyncedFolderConfig>();
public IReadOnlyList<VosProvisionerConfig> Provisioners { get; init; } = Array.Empty<VosProvisionerConfig>();
}
[Builder]
public sealed record VosNetworkConfig
{
[JsonSchemaEnum("private_network", "public_network", "forwarded_port")]
public required string Type { get; init; }
public string? Ip { get; init; }
public string? Bridge { get; init; }
public int? Guest { get; init; }
public int? Host { get; init; }
}
[Builder]
public sealed record VosSyncedFolderConfig
{
public required string Host { get; init; }
public required string Guest { get; init; }
[JsonSchemaEnum("rsync", "nfs", "smb", "virtualbox")]
public string Type { get; init; } = "rsync";
}
[Builder]
public sealed record VosProvisionerConfig
{
[JsonSchemaEnum("shell", "file", "ansible_local")]
public required string Type { get; init; }
public string? Path { get; init; }
public string? Source { get; init; }
public string? Destination { get; init; }
}[Builder]
public sealed record VosConfig
{
[JsonSchemaProperty(Required = true)]
public required IReadOnlyList<VosMachineConfig> Machines { get; init; }
[JsonSchemaProperty(Description = "Default provider for machines that don't specify one")]
[JsonSchemaEnum("virtualbox", "hyperv", "parallels", "libvirt")]
public string DefaultProvider { get; init; } = "virtualbox";
}
[Builder]
public sealed record VosMachineConfig
{
[JsonSchemaProperty(Required = true)]
public required string Name { get; init; }
[JsonSchemaProperty(Required = true)]
public required string Box { get; init; }
[JsonSchemaProperty(Description = "Optional pinned version")]
public string? BoxVersion { get; init; }
[JsonSchemaProperty(Description = "VM hostname")]
public string? Hostname { get; init; }
[JsonSchemaProperty(Minimum = 1, Maximum = 64)]
public int Cpus { get; init; } = 2;
[JsonSchemaProperty(Minimum = 256, Maximum = 131072)]
public int Memory { get; init; } = 2048;
[JsonSchemaEnum("virtualbox", "hyperv", "parallels", "libvirt")]
public string? Provider { get; init; }
public IReadOnlyList<VosNetworkConfig> Networks { get; init; } = Array.Empty<VosNetworkConfig>();
public IReadOnlyList<VosSyncedFolderConfig> SyncedFolders { get; init; } = Array.Empty<VosSyncedFolderConfig>();
public IReadOnlyList<VosProvisionerConfig> Provisioners { get; init; } = Array.Empty<VosProvisionerConfig>();
}
[Builder]
public sealed record VosNetworkConfig
{
[JsonSchemaEnum("private_network", "public_network", "forwarded_port")]
public required string Type { get; init; }
public string? Ip { get; init; }
public string? Bridge { get; init; }
public int? Guest { get; init; }
public int? Host { get; init; }
}
[Builder]
public sealed record VosSyncedFolderConfig
{
public required string Host { get; init; }
public required string Guest { get; init; }
[JsonSchemaEnum("rsync", "nfs", "smb", "virtualbox")]
public string Type { get; init; } = "rsync";
}
[Builder]
public sealed record VosProvisionerConfig
{
[JsonSchemaEnum("shell", "file", "ansible_local")]
public required string Type { get; init; }
public string? Path { get; init; }
public string? Source { get; init; }
public string? Destination { get; init; }
}The projection
Generation lives in IVosConfigGenerator, which is invoked by the Generate stage of the pipeline:
[Injectable(ServiceLifetime.Singleton)]
public sealed class VosConfigGenerator : IVosConfigGenerator
{
public VosConfig Generate(HomeLabConfig homeLab)
{
var machines = homeLab.Topology switch
{
"single" => GenerateSingleVm(homeLab),
"multi" => GenerateMultiVm(homeLab),
"ha" => GenerateHaVms(homeLab),
_ => throw new InvalidOperationException($"unknown topology: {homeLab.Topology}")
};
return new VosConfig
{
Machines = machines.ToList(),
DefaultProvider = homeLab.Vos.Provider
};
}
private IEnumerable<VosMachineConfig> GenerateSingleVm(HomeLabConfig hl)
{
yield return new VosMachineConfig
{
Name = $"{hl.Name}-main-01",
Box = hl.Vos.Box,
BoxVersion = hl.Vos.BoxVersion,
Hostname = $"main.{hl.Acme.Tld}",
Cpus = hl.Vos.Cpus,
Memory = hl.Vos.Memory,
Provider = hl.Vos.Provider,
Networks = new[]
{
new VosNetworkConfig { Type = "private_network", Ip = $"{hl.Vos.Subnet}.10" }
},
SyncedFolders = new[]
{
new VosSyncedFolderConfig { Host = "./data/certs", Guest = "/etc/ssl/devlab" }
},
Provisioners = StandardProvisioners()
};
}
private IEnumerable<VosMachineConfig> GenerateMultiVm(HomeLabConfig hl)
{
var subnet = hl.Vos.Subnet;
yield return new VosMachineConfig
{
Name = $"{hl.Name}-gateway",
Box = hl.Vos.Box,
Hostname = $"gateway.{hl.Acme.Tld}",
Cpus = 2, Memory = 1024,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.10" } },
SyncedFolders = new[] { new VosSyncedFolderConfig { Host = "./data/certs", Guest = "/etc/ssl/devlab" } },
Provisioners = StandardProvisioners()
};
yield return new VosMachineConfig
{
Name = $"{hl.Name}-platform",
Box = hl.Vos.Box,
Hostname = $"gitlab.{hl.Acme.Tld}",
Cpus = 4, Memory = 8192,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.11" } },
Provisioners = StandardProvisioners()
};
yield return new VosMachineConfig
{
Name = $"{hl.Name}-data",
Box = hl.Vos.Box,
Hostname = $"data.{hl.Acme.Tld}",
Cpus = 2, Memory = 4096,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.12" } },
Provisioners = StandardProvisioners()
};
yield return new VosMachineConfig
{
Name = $"{hl.Name}-obs",
Box = hl.Vos.Box,
Hostname = $"obs.{hl.Acme.Tld}",
Cpus = 2, Memory = 2048,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.13" } },
Provisioners = StandardProvisioners()
};
}
private IEnumerable<VosMachineConfig> GenerateHaVms(HomeLabConfig hl)
{
// 2x rails, 3x gitaly+praefect, 3x patroni postgres, 3x redis sentinel, 1x consul, 1x lb
// (full HA Reference Architecture — see Part 39)
// ~12 machines, allocated from .10 upwards
// ...
}
private static IReadOnlyList<VosProvisionerConfig> StandardProvisioners() => new[]
{
new VosProvisionerConfig { Type = "shell", Path = "provisioning/enable-docker-tcp.sh" }
};
}[Injectable(ServiceLifetime.Singleton)]
public sealed class VosConfigGenerator : IVosConfigGenerator
{
public VosConfig Generate(HomeLabConfig homeLab)
{
var machines = homeLab.Topology switch
{
"single" => GenerateSingleVm(homeLab),
"multi" => GenerateMultiVm(homeLab),
"ha" => GenerateHaVms(homeLab),
_ => throw new InvalidOperationException($"unknown topology: {homeLab.Topology}")
};
return new VosConfig
{
Machines = machines.ToList(),
DefaultProvider = homeLab.Vos.Provider
};
}
private IEnumerable<VosMachineConfig> GenerateSingleVm(HomeLabConfig hl)
{
yield return new VosMachineConfig
{
Name = $"{hl.Name}-main-01",
Box = hl.Vos.Box,
BoxVersion = hl.Vos.BoxVersion,
Hostname = $"main.{hl.Acme.Tld}",
Cpus = hl.Vos.Cpus,
Memory = hl.Vos.Memory,
Provider = hl.Vos.Provider,
Networks = new[]
{
new VosNetworkConfig { Type = "private_network", Ip = $"{hl.Vos.Subnet}.10" }
},
SyncedFolders = new[]
{
new VosSyncedFolderConfig { Host = "./data/certs", Guest = "/etc/ssl/devlab" }
},
Provisioners = StandardProvisioners()
};
}
private IEnumerable<VosMachineConfig> GenerateMultiVm(HomeLabConfig hl)
{
var subnet = hl.Vos.Subnet;
yield return new VosMachineConfig
{
Name = $"{hl.Name}-gateway",
Box = hl.Vos.Box,
Hostname = $"gateway.{hl.Acme.Tld}",
Cpus = 2, Memory = 1024,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.10" } },
SyncedFolders = new[] { new VosSyncedFolderConfig { Host = "./data/certs", Guest = "/etc/ssl/devlab" } },
Provisioners = StandardProvisioners()
};
yield return new VosMachineConfig
{
Name = $"{hl.Name}-platform",
Box = hl.Vos.Box,
Hostname = $"gitlab.{hl.Acme.Tld}",
Cpus = 4, Memory = 8192,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.11" } },
Provisioners = StandardProvisioners()
};
yield return new VosMachineConfig
{
Name = $"{hl.Name}-data",
Box = hl.Vos.Box,
Hostname = $"data.{hl.Acme.Tld}",
Cpus = 2, Memory = 4096,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.12" } },
Provisioners = StandardProvisioners()
};
yield return new VosMachineConfig
{
Name = $"{hl.Name}-obs",
Box = hl.Vos.Box,
Hostname = $"obs.{hl.Acme.Tld}",
Cpus = 2, Memory = 2048,
Provider = hl.Vos.Provider,
Networks = new[] { new VosNetworkConfig { Type = "private_network", Ip = $"{subnet}.13" } },
Provisioners = StandardProvisioners()
};
}
private IEnumerable<VosMachineConfig> GenerateHaVms(HomeLabConfig hl)
{
// 2x rails, 3x gitaly+praefect, 3x patroni postgres, 3x redis sentinel, 1x consul, 1x lb
// (full HA Reference Architecture — see Part 39)
// ~12 machines, allocated from .10 upwards
// ...
}
private static IReadOnlyList<VosProvisionerConfig> StandardProvisioners() => new[]
{
new VosProvisionerConfig { Type = "shell", Path = "provisioning/enable-docker-tcp.sh" }
};
}The projector is one method per topology. Each topology is a deterministic function of the config. Generation is pure: (HomeLabConfig) → VosConfig. No side effects.
Local overrides
Just like config-homelab.yaml has local/config-homelab-local.yaml (see Part 05), config-vos.yaml has local/config-vos-local.yaml. The override is deep-merged at Vagrant load time — by the Ruby code in the fixed Vagrantfile, not by HomeLab. This is intentional: the user might want to override a machine's memory on their personal workstation without regenerating anything.
# local/config-vos-local.yaml — gitignored, edited by hand on each machine
machines:
- name: devlab-platform # match by name
cpus: 8 # I have spare cores
memory: 16384 # and lots of RAM# local/config-vos-local.yaml — gitignored, edited by hand on each machine
machines:
- name: devlab-platform # match by name
cpus: 8 # I have spare cores
memory: 16384 # and lots of RAMThe Vagrantfile's merge logic deep-merges by name. Each entry in local's machines list is matched against config-vos.yaml's machines list and overlays the matching one. New machines in local are appended.
The wiring
[Injectable(ServiceLifetime.Singleton)]
public sealed class VosInitRequestHandler : IRequestHandler<VosInitRequest, Result<VosInitResponse>>
{
private readonly IVosConfigGenerator _generator;
private readonly IBundleWriter _writer;
private readonly IFileSystem _fs;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
public async Task<Result<VosInitResponse>> HandleAsync(VosInitRequest req, CancellationToken ct)
{
var config = await LoadHomeLabConfigAsync(req.ConfigPath, ct);
if (config.IsFailure) return config.Map<VosInitResponse>();
var vosConfig = _generator.Generate(config.Value);
// 1. Write config-vos.yaml
var yamlResult = await _writer.WriteYamlAsync(vosConfig, req.OutputDir, "config-vos.yaml", ct);
if (yamlResult.IsFailure) return yamlResult.Map<VosInitResponse>();
// 2. Write the fixed Vagrantfile (always the same — overwrite always)
var vagrantfilePath = Path.Combine(req.OutputDir.FullName, "Vagrantfile");
await _fs.File.WriteAllTextAsync(vagrantfilePath, FixedVagrantfile.Content, ct);
// 3. Write the JSON schema for VSCode
var schemaResult = await _writer.WriteJsonAsync(VosConfigSchema.Schema, req.OutputDir, "schemas/vos-config.schema.json", ct);
// 4. Write the .vscode/settings.json that wires the schema
// ...
await _events.PublishAsync(new VosInitCompleted(vosConfig.Machines.Count, _clock.UtcNow), ct);
return Result.Success(new VosInitResponse(MachinesGenerated: vosConfig.Machines.Count));
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class VosInitRequestHandler : IRequestHandler<VosInitRequest, Result<VosInitResponse>>
{
private readonly IVosConfigGenerator _generator;
private readonly IBundleWriter _writer;
private readonly IFileSystem _fs;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
public async Task<Result<VosInitResponse>> HandleAsync(VosInitRequest req, CancellationToken ct)
{
var config = await LoadHomeLabConfigAsync(req.ConfigPath, ct);
if (config.IsFailure) return config.Map<VosInitResponse>();
var vosConfig = _generator.Generate(config.Value);
// 1. Write config-vos.yaml
var yamlResult = await _writer.WriteYamlAsync(vosConfig, req.OutputDir, "config-vos.yaml", ct);
if (yamlResult.IsFailure) return yamlResult.Map<VosInitResponse>();
// 2. Write the fixed Vagrantfile (always the same — overwrite always)
var vagrantfilePath = Path.Combine(req.OutputDir.FullName, "Vagrantfile");
await _fs.File.WriteAllTextAsync(vagrantfilePath, FixedVagrantfile.Content, ct);
// 3. Write the JSON schema for VSCode
var schemaResult = await _writer.WriteJsonAsync(VosConfigSchema.Schema, req.OutputDir, "schemas/vos-config.schema.json", ct);
// 4. Write the .vscode/settings.json that wires the schema
// ...
await _events.PublishAsync(new VosInitCompleted(vosConfig.Machines.Count, _clock.UtcNow), ct);
return Result.Success(new VosInitResponse(MachinesGenerated: vosConfig.Machines.Count));
}
}The handler does four things: project, write the YAML, write the fixed Vagrantfile, write the schema. All four go through IBundleWriter (DRY — see Part 08).
The test
[Fact]
public void single_topology_generates_one_machine()
{
var config = new HomeLabConfig
{
Name = "test",
Topology = "single",
Acme = new() { Name = "test", Tld = "lab" },
Vos = new() { Box = "x/y", Cpus = 4, Memory = 4096, Subnet = "192.168.56", Provider = "virtualbox" }
};
var vos = new VosConfigGenerator().Generate(config);
vos.Machines.Should().ContainSingle();
vos.Machines[0].Name.Should().Be("test-main-01");
vos.Machines[0].Cpus.Should().Be(4);
vos.Machines[0].Networks[0].Ip.Should().Be("192.168.56.10");
}
[Fact]
public void multi_topology_generates_four_machines_with_correct_ips()
{
var config = new HomeLabConfig
{
Name = "dl",
Topology = "multi",
Acme = new() { Name = "fxd", Tld = "lab" },
Vos = new() { Box = "x/y", Subnet = "192.168.56", Provider = "virtualbox" }
};
var vos = new VosConfigGenerator().Generate(config);
vos.Machines.Should().HaveCount(4);
vos.Machines.Select(m => m.Name).Should().Equal("dl-gateway", "dl-platform", "dl-data", "dl-obs");
vos.Machines.Select(m => m.Networks[0].Ip).Should().Equal(
"192.168.56.10", "192.168.56.11", "192.168.56.12", "192.168.56.13");
}
[Fact]
public async Task vos_init_writes_yaml_vagrantfile_and_schema()
{
var fs = new MockFileSystem();
var handler = TestHandlers.VosInit(fs, generator: new VosConfigGenerator());
var outputDir = new DirectoryInfo("/lab");
fs.AddFile("/lab/config-homelab.yaml", new MockFileData("name: t\ntopology: single"));
var result = await handler.HandleAsync(
new VosInitRequest(new("/lab/config-homelab.yaml"), outputDir),
CancellationToken.None);
result.IsSuccess.Should().BeTrue();
fs.FileExists("/lab/config-vos.yaml").Should().BeTrue();
fs.FileExists("/lab/Vagrantfile").Should().BeTrue();
fs.FileExists("/lab/schemas/vos-config.schema.json").Should().BeTrue();
}[Fact]
public void single_topology_generates_one_machine()
{
var config = new HomeLabConfig
{
Name = "test",
Topology = "single",
Acme = new() { Name = "test", Tld = "lab" },
Vos = new() { Box = "x/y", Cpus = 4, Memory = 4096, Subnet = "192.168.56", Provider = "virtualbox" }
};
var vos = new VosConfigGenerator().Generate(config);
vos.Machines.Should().ContainSingle();
vos.Machines[0].Name.Should().Be("test-main-01");
vos.Machines[0].Cpus.Should().Be(4);
vos.Machines[0].Networks[0].Ip.Should().Be("192.168.56.10");
}
[Fact]
public void multi_topology_generates_four_machines_with_correct_ips()
{
var config = new HomeLabConfig
{
Name = "dl",
Topology = "multi",
Acme = new() { Name = "fxd", Tld = "lab" },
Vos = new() { Box = "x/y", Subnet = "192.168.56", Provider = "virtualbox" }
};
var vos = new VosConfigGenerator().Generate(config);
vos.Machines.Should().HaveCount(4);
vos.Machines.Select(m => m.Name).Should().Equal("dl-gateway", "dl-platform", "dl-data", "dl-obs");
vos.Machines.Select(m => m.Networks[0].Ip).Should().Equal(
"192.168.56.10", "192.168.56.11", "192.168.56.12", "192.168.56.13");
}
[Fact]
public async Task vos_init_writes_yaml_vagrantfile_and_schema()
{
var fs = new MockFileSystem();
var handler = TestHandlers.VosInit(fs, generator: new VosConfigGenerator());
var outputDir = new DirectoryInfo("/lab");
fs.AddFile("/lab/config-homelab.yaml", new MockFileData("name: t\ntopology: single"));
var result = await handler.HandleAsync(
new VosInitRequest(new("/lab/config-homelab.yaml"), outputDir),
CancellationToken.None);
result.IsSuccess.Should().BeTrue();
fs.FileExists("/lab/config-vos.yaml").Should().BeTrue();
fs.FileExists("/lab/Vagrantfile").Should().BeTrue();
fs.FileExists("/lab/schemas/vos-config.schema.json").Should().BeTrue();
}What this gives you that bash doesn't
A bash script that templates a Vagrantfile is a heredoc inside a function inside a script that nobody can read after six months. The interpolation rules of bash collide with the interpolation rules of Ruby. Every fix introduces a quoting bug.
A typed projection from HomeLabConfig to VosConfig, written to YAML by IBundleWriter, consumed by a fixed Vagrantfile, gives you, for the same surface area:
- One generation function per topology, deterministic, unit-testable
- Schema validation of the YAML in VSCode and in CI
- Deep-merge with local overrides at Vagrant load time
- A fixed Vagrantfile committed once, never edited
- Tests for the projection and the wiring, both in milliseconds
The bargain pays back the first time you switch from single to multi and discover that the regeneration produces exactly the four machines you expected, in the right subnet, with the right hostnames, in one command.