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 04: Schema-Validated YAML — Intellisense or Bust

"Configuration files without schema validation are a liability. They look simple until someone misspells a key and spends an hour debugging why the VM has 1 GB of RAM instead of 4 GB."HomeLab/doc/PHILOSOPHY.md


Why

Part 03 ended with a question: where does the typed HomeLabRequest come from? In the test harness, it comes from a constructor call. In production, it comes from a YAML file the user edits in VSCode and a few CLI flags that override individual fields.

That YAML file is the only piece of human-authored text in the entire HomeLab workflow. Everything else — Packer HCL, Vagrantfile, compose YAML, Traefik YAML, gitlab.rb, certs, DNS entries — is generated. So the YAML file is, in a very literal sense, the entire user interface.

If the user interface is untyped, the user interface is the drift surface. We just spent Part 01 proving that untyped configuration is a Saturday-afternoon factory. Letting config-homelab.yaml be a free-form YAML file — even one with a "schema" written by hand in Markdown — would mean we built a typed pipeline, a typed plugin system, a typed event bus, and a typed lib, and then handed the user a string and said "good luck".

The thesis of this part is: config-homelab.yaml has a JSON Schema, the JSON Schema is generated from C# [Builder] types at build time, and VSCode validates it on every keystroke. Typos are caught the moment they are typed. Required fields glow red until they are filled. Enums autocomplete. Numeric ranges complain when violated. The schema is in version control, generated by dotnet build, never hand-edited, and impossible to drift from the C# types it describes — because it is the C# types.


The shape

The root config type is a plain C# record with [Builder] from FrenchExDev.Net.Builder. Every nested type is also a [Builder] record. Every property has a [JsonSchemaProperty] attribute that the schema generator reads.

[Builder]
public sealed record HomeLabConfig
{
    [JsonSchemaProperty(Required = true, Description = "Lab instance name (must be unique on this machine)")]
    public required string Name { get; init; }

    [JsonSchemaProperty(Required = true, Description = "Topology: single (1 VM), multi (4 VMs), ha (~10 VMs)")]
    [JsonSchemaEnum("single", "multi", "ha")]
    public required string Topology { get; init; }

    [JsonSchemaProperty(Description = "Organization metadata (used for box names and hostnames)")]
    public AcmeConfig Acme { get; init; } = new();

    [JsonSchemaProperty(Description = "Packer image build settings")]
    public PackerConfig Packer { get; init; } = new();

    [JsonSchemaProperty(Description = "Vos / Vagrant VM provisioning")]
    public VosConfig Vos { get; init; } = new();

    [JsonSchemaProperty(Description = "Docker Compose service stacks")]
    public ComposeConfig Compose { get; init; } = new();

    [JsonSchemaProperty(Description = "TLS certificate generation")]
    public TlsConfig Tls { get; init; } = new();

    [JsonSchemaProperty(Description = "DNS management")]
    public DnsConfig Dns { get; init; } = new();

    [JsonSchemaProperty(Description = "GitLab Omnibus configuration")]
    public GitLabConfig GitLab { get; init; } = new();

    [JsonSchemaProperty(Description = "Container engine: docker | podman")]
    [JsonSchemaEnum("docker", "podman")]
    public string Engine { get; init; } = "docker";

    [JsonSchemaProperty(Description = "Plugins to load (NuGet IDs)")]
    public IReadOnlyList<string> Plugins { get; init; } = Array.Empty<string>();
}

[Builder]
public sealed record VosConfig
{
    [JsonSchemaProperty(Description = "Vagrant box name")]
    public string Box { get; init; } = "frenchexdev/alpine-3.21-dockerhost";

    [JsonSchemaProperty(Description = "Memory per VM (MB)", Minimum = 512, Maximum = 65536)]
    public int Memory { get; init; } = 2048;

    [JsonSchemaProperty(Description = "vCPUs per VM", Minimum = 1, Maximum = 32)]
    public int Cpus { get; init; } = 4;

    [JsonSchemaProperty(Description = "Private host-only subnet (e.g. 192.168.56)")]
    [JsonSchemaPattern(@"^\d{1,3}\.\d{1,3}\.\d{1,3}$")]
    public string Subnet { get; init; } = "192.168.56";

    [JsonSchemaProperty(Description = "Hypervisor provider")]
    [JsonSchemaEnum("virtualbox", "hyperv", "parallels")]
    public string Provider { get; init; } = "virtualbox";
}

A user editing the YAML in VSCode sees:

  • Autocomplete on every key (because the schema knows the property names)
  • Hover-text on every key (because the schema includes the descriptions)
  • Red squiggles on misspellings (because the schema marks unknown keys as additional properties)
  • Red squiggles on out-of-range numbers (because of Minimum / Maximum)
  • Red squiggles on bad enum values (because of JsonSchemaEnum)
  • Red squiggles on subnet strings that don't match the regex
  • Red squiggles on missing required fields (because of Required = true)

All of this without running a single line of HomeLab code.


Step 1: Generate the schema at build time

The build invokes a small console tool that reflects over the HomeLabConfig types and emits a JSON Schema:

<!-- src/HomeLab/HomeLab.csproj -->
<Target Name="GenerateHomeLabSchema" AfterTargets="Build">
  <Exec Command="dotnet $(MSBuildProjectDirectory)/../HomeLab.SchemaGen/bin/$(Configuration)/net10.0/HomeLab.SchemaGen.dll
                 --assembly $(TargetPath)
                 --root FrenchExDev.Net.HomeLab.Configuration.HomeLabConfig
                 --output $(MSBuildProjectDirectory)/schemas/homelab-config.schema.json" />
</Target>

The schema generator is ~150 lines using JsonSchema.Net (or NJsonSchema — pick one and stick with it). It reads the [JsonSchemaProperty] / [JsonSchemaEnum] / [JsonSchemaPattern] attributes, walks the type graph, and emits a draft-2020-12 schema that ships with the lib.

// schemas/homelab-config.schema.json (excerpt, generated)
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://frenchexdev.lab/schemas/homelab-config.schema.json",
  "title": "HomeLabConfig",
  "type": "object",
  "required": ["name", "topology"],
  "additionalProperties": false,
  "properties": {
    "name": {
      "type": "string",
      "description": "Lab instance name (must be unique on this machine)"
    },
    "topology": {
      "type": "string",
      "enum": ["single", "multi", "ha"],
      "description": "Topology: single (1 VM), multi (4 VMs), ha (~10 VMs)"
    },
    "vos": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "box":      { "type": "string" },
        "memory":   { "type": "integer", "minimum": 512, "maximum": 65536 },
        "cpus":     { "type": "integer", "minimum": 1, "maximum": 32 },
        "subnet":   { "type": "string", "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$" },
        "provider": { "type": "string", "enum": ["virtualbox", "hyperv", "parallels"] }
      }
    }
    // ...
  }
}

The schema is written to schemas/homelab-config.schema.json next to the source. It is committed to the repo. CI fails if dotnet build produces a schema that differs from the committed copy (a one-line git diff --exit-code step). This means the schema is always up to date with the C# types, and schema changes show up in code review as visible diffs.

Step 2: Wire VSCode

homelab init writes a .vscode/settings.json next to the new config-homelab.yaml:

{
  "yaml.schemas": {
    "./schemas/homelab-config.schema.json": ["config-homelab.yaml", "config-homelab*.yaml"],
    "./schemas/vos-config.schema.json":     ["config-vos.yaml",     "config-vos*.yaml"]
  },
  "yaml.format.enable": true,
  "yaml.validate": true
}

The user installs the Red Hat YAML extension once. After that, every YAML file with a matching name in the project gets full schema validation, autocomplete, hover help, and error squiggles. No HomeLab code runs. The user is editing free-form YAML, but free-form YAML guarded by a schema generated from the same types HomeLab will deserialise it into.

Step 3: Validate in the CLI

homelab validate runs the same schema check the editor runs, plus a deeper validation that uses the C# types directly. The schema catches structural errors. The C# layer catches semantic errors (cross-field invariants, plugin-specific constraints, Ops.Dsl [MetaConstraint] validation):

[Injectable(ServiceLifetime.Singleton)]
public sealed class ValidateRequestHandler : IHomeLabRequestHandler<ValidateRequest, ValidateResponse>
{
    private readonly IHomeLabConfigLoader _loader;
    private readonly IEnumerable<IHomeLabConfigValidator> _validators;
    private readonly IMetaConstraintRunner _opsDslConstraints;

    public async Task<Result<ValidateResponse>> HandleAsync(ValidateRequest req, CancellationToken ct)
    {
        // 1. Schema validation (structural — same as VSCode)
        var loadResult = await _loader.LoadAsync(req.ConfigPath, ct);
        if (loadResult.IsFailure)
            return loadResult.Map<ValidateResponse>();

        var config = loadResult.Value;

        // 2. Cross-field validation (semantic — invariants spanning multiple fields)
        var errors = new List<string>();
        foreach (var validator in _validators)
        {
            var r = validator.Validate(config);
            if (r.IsFailure)
                errors.AddRange(r.Errors);
        }

        // 3. Ops.Dsl [MetaConstraint] validation
        //    (the typed verbs of operational state — see Part 12)
        var dslErrors = _opsDslConstraints.Validate(config);
        errors.AddRange(dslErrors);

        return errors.Count == 0
            ? Result.Success(new ValidateResponse(Issues: Array.Empty<string>()))
            : Result.Failure<ValidateResponse>(string.Join("\n", errors));
    }
}

The user runs homelab validate in CI before any other command. If the YAML has structural errors, schema validation catches them. If the YAML has cross-field errors (e.g. topology: ha requires at least 10 VMs but vos.machines only declares 4), the cross-field validators catch them. If the YAML violates an Ops.Dsl [MetaConstraint] (e.g. an OpsObservability.AlertRule references a metric that no service emits), the constraint runner catches that. Three layers, one command, exit code 0 or 1.


The test

public sealed class HomeLabConfigSchemaTests
{
    [Fact]
    public async Task valid_minimal_config_passes_schema()
    {
        var yaml = """
            name: ci-test
            topology: single
            """;
        var result = await SchemaValidator.ValidateAsync(yaml, "homelab-config.schema.json");
        result.IsValid.Should().BeTrue();
    }

    [Fact]
    public async Task missing_required_topology_fails_schema()
    {
        var yaml = "name: ci-test";
        var result = await SchemaValidator.ValidateAsync(yaml, "homelab-config.schema.json");
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.Contains("'topology'"));
    }

    [Fact]
    public async Task unknown_top_level_property_fails_schema()
    {
        var yaml = """
            name: ci-test
            topology: single
            potato: true
            """;
        var result = await SchemaValidator.ValidateAsync(yaml, "homelab-config.schema.json");
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.Contains("potato"));
    }

    [Fact]
    public async Task vos_memory_below_minimum_fails_schema()
    {
        var yaml = """
            name: ci-test
            topology: single
            vos: { memory: 256 }
            """;
        var result = await SchemaValidator.ValidateAsync(yaml, "homelab-config.schema.json");
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.Contains("256") && e.Contains("minimum"));
    }

    [Fact]
    public async Task topology_ha_with_too_few_machines_fails_cross_field()
    {
        var yaml = """
            name: ci-test
            topology: ha
            vos: { machines: [{name: only-one}] }
            """;
        // Schema says it's fine (machines is a list, ha is a valid enum value)
        var schemaResult = await SchemaValidator.ValidateAsync(yaml, "homelab-config.schema.json");
        schemaResult.IsValid.Should().BeTrue();

        // But cross-field validation says no
        var loaded = await new HomeLabConfigLoader().LoadAsync(yaml);
        var validator = new HaTopologyMachineCountValidator();
        var crossField = validator.Validate(loaded.Value);
        crossField.IsFailure.Should().BeTrue();
        crossField.Errors.Should().Contain(e => e.Contains("ha") && e.Contains("at least 9"));
    }

    [Fact]
    public void schema_is_in_sync_with_csharp_types()
    {
        var fromDisk = File.ReadAllText("schemas/homelab-config.schema.json");
        var freshlyGenerated = SchemaGenerator.Generate(typeof(HomeLabConfig));
        freshlyGenerated.Should().Be(fromDisk,
            "the committed schema must match the C# types — run `dotnet build` and commit the diff");
    }
}

Six tests. Five exercise the validation surface; the sixth is the architecture test that prevents the schema from drifting from the types. That last test is the most important one, and it is the cheapest. It runs in 80 milliseconds. It catches every "I forgot to regenerate the schema after renaming the property" mistake before it ever reaches a user.


What this gives you that bash doesn't

Bash configuration is environment variables, command-line flags, and "the script reads ~/.homelabrc if it exists, I think". There is no autocomplete. There is no hover help. There is no validation. There is no schema. There is no test that proves the script reads the keys it claims to read. There is no test that proves the script writes the keys it claims to write. There is set -u, which catches undefined variables, and there is getopts, which catches missing flags, and that is it.

Schema-validated YAML with [Builder] types and a generated JSON Schema gives you, for the same surface area:

  • Autocomplete in VSCode with no extension other than the standard YAML one
  • Hover help generated from the same C# XML doc / [Description] attributes that document the type
  • Red squiggles on typos at edit time, before any command runs
  • Required field enforcement at edit time
  • Numeric range checking at edit time
  • Enum value checking at edit time
  • Regex pattern checking at edit time
  • Cross-field validation at homelab validate time
  • Ops.Dsl [MetaConstraint] validation at homelab validate time
  • An architecture test that prevents schema drift from C# types

The user types topo and the editor offers topology. The user types topology: hat and the editor underlines hat. The user types vos: { memory: 256 } and the editor says "minimum is 512". The user types vos: { provider: vbox } and the editor offers virtualbox, hyperv, parallels. The user runs homelab validate in CI and gets exit code 1 with a clear error before any VM is touched.

That is what typed configuration means in practice. Not "we have a docs page that lists the keys". Not "we have a Markdown table somewhere". Typed. As in: your editor knows. As in: the compiler knows. As in: the test suite knows. As in: when you rename a property in the C# type, the schema regenerates, the tests run, and the YAML files in your repo get red squiggles until you update them — without any human having to remember to do it.


A note on local/*-local.yaml

Part 05 goes into the details of git-composable config — shared base in config-homelab.yaml, personal overrides in local/config-homelab-local.yaml. Both files are validated against the same schema, with the local file allowed to override any subset of the base. The merge happens in the IHomeLabConfigLoader, and the merged result is the one that flows into the pipeline. Schema validation runs on both files individually and on the merged result, because cross-field invariants only become visible after the merge.


⬇ Download