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 08: The K8s Topology Resolver

"The topology is a function. Single, multi, HA — same input shape, different output sizes."


Why

Part 02 gave the budget. This part gives the projection: from a single config field (topology: k8s-single | k8s-multi | k8s-ha) to a list of VosMachineConfig records that the existing HomeLab pipeline knows how to provision.

The thesis is the same as homelab-docker Part 30: topology is one config field, and the resolver is a deterministic function of the config plus the topology choice. K8s.Dsl ships a K8sTopologyResolver that extends the existing StandardTopologyResolver with three new cases (k8s-single, k8s-multi, k8s-ha). The contributors do not branch on topology; the resolver branches once.


The shape

public interface IK8sTopologyResolver
{
    bool Handles(string topology);
    IReadOnlyList<VosMachineConfig> Resolve(string topology, HomeLabConfig config);
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class K8sTopologyResolver : IK8sTopologyResolver
{
    public bool Handles(string topology) =>
        topology == "k8s-single" || topology == "k8s-multi" || topology == "k8s-ha";

    public IReadOnlyList<VosMachineConfig> Resolve(string topology, HomeLabConfig config) =>
        topology switch
        {
            "k8s-single" => SingleNode(config).ToList(),
            "k8s-multi"  => MultiNode(config).ToList(),
            "k8s-ha"     => HaNodes(config).ToList(),
            _ => throw new InvalidOperationException()
        };

    private IEnumerable<VosMachineConfig> SingleNode(HomeLabConfig hl)
    {
        var subnet = hl.Vos.Subnet;
        yield return new VosMachineConfig
        {
            Name = $"{hl.Name}-main",
            Box = hl.K8s!.NodeBox,
            Hostname = $"main.{hl.Acme.Tld}",
            Cpus = Math.Max(hl.Vos.Cpus, 4),
            Memory = Math.Max(hl.Vos.Memory, 16384),
            DiskGb = 80,
            Provider = hl.Vos.Provider,
            Networks = new[] { Net($"{subnet}.10") },
            Role = "k8s-control-plane-and-worker",
            Labels = new() { ["k8s.role"] = "all-in-one" },
            Provisioners = K8sProvisioners(hl)
        };
    }

    private IEnumerable<VosMachineConfig> MultiNode(HomeLabConfig hl)
    {
        var subnet = hl.Vos.Subnet;

        yield return new VosMachineConfig
        {
            Name = $"{hl.Name}-cp-1",
            Box = hl.K8s!.NodeBox,
            Hostname = $"cp-1.{hl.Acme.Tld}",
            Cpus = 2, Memory = 4096, DiskGb = 40,
            Provider = hl.Vos.Provider,
            Networks = new[] { Net($"{subnet}.10") },
            Role = "k8s-control-plane",
            Labels = new() { ["k8s.role"] = "control-plane" },
            Provisioners = K8sProvisioners(hl)
        };

        for (var i = 1; i <= 3; i++)
        {
            yield return new VosMachineConfig
            {
                Name = $"{hl.Name}-w-{i}",
                Box = hl.K8s!.NodeBox,
                Hostname = $"w-{i}.{hl.Acme.Tld}",
                Cpus = 2, Memory = 8192, DiskGb = 60,
                Provider = hl.Vos.Provider,
                Networks = new[] { Net($"{subnet}.{20 + i}") },
                Role = "k8s-worker",
                Labels = new() { ["k8s.role"] = "worker" },
                Provisioners = K8sProvisioners(hl)
            };
        }
    }

    private IEnumerable<VosMachineConfig> HaNodes(HomeLabConfig hl)
    {
        var subnet = hl.Vos.Subnet;

        // 1 load balancer (kube-vip alternative — HAProxy + keepalived running on a separate VM)
        yield return new VosMachineConfig
        {
            Name = $"{hl.Name}-lb",
            Box = hl.K8s!.NodeBox,
            Hostname = $"lb.{hl.Acme.Tld}",
            Cpus = 1, Memory = 1024, DiskGb = 20,
            Provider = hl.Vos.Provider,
            Networks = new[] { Net($"{subnet}.10") },
            Role = "k8s-load-balancer",
            Labels = new() { ["k8s.role"] = "load-balancer" },
            Provisioners = LbProvisioners(hl)
        };

        // 3 control planes (etcd quorum)
        for (var i = 1; i <= 3; i++)
        {
            yield return new VosMachineConfig
            {
                Name = $"{hl.Name}-cp-{i}",
                Box = hl.K8s!.NodeBox,
                Hostname = $"cp-{i}.{hl.Acme.Tld}",
                Cpus = 2, Memory = 4096, DiskGb = 40,
                Provider = hl.Vos.Provider,
                Networks = new[] { Net($"{subnet}.{10 + i}") },
                Role = "k8s-control-plane",
                Labels = new() { ["k8s.role"] = "control-plane", ["k8s.etcd"] = "true" },
                Provisioners = K8sProvisioners(hl)
            };
        }

        // 3 workers minimum
        for (var i = 1; i <= 3; i++)
        {
            yield return new VosMachineConfig
            {
                Name = $"{hl.Name}-w-{i}",
                Box = hl.K8s!.NodeBox,
                Hostname = $"w-{i}.{hl.Acme.Tld}",
                Cpus = 2, Memory = 8192, DiskGb = 60,
                Provider = hl.Vos.Provider,
                Networks = new[] { Net($"{subnet}.{20 + i}") },
                Role = "k8s-worker",
                Labels = new() { ["k8s.role"] = "worker" },
                Provisioners = K8sProvisioners(hl)
            };
        }
    }

    private static VosNetworkConfig Net(string ip) => new() { Type = "private_network", Ip = ip };

    private static IReadOnlyList<VosProvisionerConfig> K8sProvisioners(HomeLabConfig hl) => new[]
    {
        new VosProvisionerConfig
        {
            Type = "shell",
            Path = $"provisioning/k8s-{hl.K8s!.Distribution}-prepare.sh"
        }
    };

    private static IReadOnlyList<VosProvisionerConfig> LbProvisioners(HomeLabConfig hl) => new[]
    {
        new VosProvisionerConfig
        {
            Type = "shell",
            Path = "provisioning/k8s-haproxy-prepare.sh"
        }
    };
}

The resolver is one method per topology. Each method yields a list of VMs. The IPs are deterministic: .10 for the first VM, .10+i for control planes, .20+i for workers. Two HomeLab instances on different subnets produce non-colliding IP plans because the subnet differs.


How it slots into the existing topology resolver

The existing StandardTopologyResolver from homelab-docker Part 30 handles single, multi, ha for the Compose stack. It does not handle k8s-*. To add the new cases without modifying the core, K8s.Dsl ships a delegating resolver that the composition root picks based on which one declares it Handles the topology:

[Injectable(ServiceLifetime.Singleton)]
public sealed class CompositeTopologyResolver : ITopologyResolver
{
    private readonly IEnumerable<IK8sTopologyResolver> _k8sResolvers;
    private readonly StandardTopologyResolver _standardResolver;

    public CompositeTopologyResolver(
        IEnumerable<IK8sTopologyResolver> k8sResolvers,
        StandardTopologyResolver standardResolver)
    {
        _k8sResolvers = k8sResolvers;
        _standardResolver = standardResolver;
    }

    public IReadOnlyList<VosMachineConfig> Resolve(Topology topology, HomeLabConfig config)
    {
        var topoString = topology.ToString().ToLowerInvariant();

        var k8sHandler = _k8sResolvers.FirstOrDefault(r => r.Handles(topoString));
        if (k8sHandler is not null)
            return k8sHandler.Resolve(topoString, config);

        return _standardResolver.Resolve(topology, config);
    }
}

The composite resolver tries each plugin-supplied K8s resolver first, then falls back to the standard one. The standard resolver still handles single/multi/ha (the Docker Compose topologies); the K8s resolver handles k8s-single/k8s-multi/k8s-ha. They do not overlap.

This is the key trick that lets a plugin extend a core service without modifying it: the core service is registered as one implementation, the plugin ships an additional implementation, and a composite delegate (also [Injectable]) is what consumers actually receive.


The Mermaid diagram

Diagram
One topology field, four VM declarations — the k8s resolver is the same projector function as the Docker topologies, just aimed at a control-plane-plus-workers shape.

Single config field, four VM declarations. Same projector function for any HomeLab instance.


The wiring

K8sTopologyResolver is [Injectable(ServiceLifetime.Singleton)]. The CompositeTopologyResolver collects every IK8sTopologyResolver via DI and tries them in order. The Plan stage of the pipeline (from homelab-docker Part 07) consumes the composite resolver and gets back a IReadOnlyList<VosMachineConfig>. From the pipeline's perspective, k8s topologies are just topologies.


The test

public sealed class K8sTopologyResolverTests
{
    [Fact]
    public void k8s_single_returns_one_vm_with_combined_role()
    {
        var resolver = new K8sTopologyResolver();
        var machines = resolver.Resolve("k8s-single", StandardK8sConfig());

        machines.Should().ContainSingle();
        machines[0].Role.Should().Be("k8s-control-plane-and-worker");
        machines[0].Memory.Should().BeGreaterOrEqualTo(16384);
    }

    [Fact]
    public void k8s_multi_returns_one_cp_and_three_workers()
    {
        var resolver = new K8sTopologyResolver();
        var machines = resolver.Resolve("k8s-multi", StandardK8sConfig());

        machines.Should().HaveCount(4);
        machines.Where(m => m.Role == "k8s-control-plane").Should().HaveCount(1);
        machines.Where(m => m.Role == "k8s-worker").Should().HaveCount(3);

        var totalRam = machines.Sum(m => m.Memory);
        totalRam.Should().BeLessOrEqualTo(32768);   // fits the 32 GB budget
    }

    [Fact]
    public void k8s_ha_returns_lb_three_cp_three_workers()
    {
        var resolver = new K8sTopologyResolver();
        var machines = resolver.Resolve("k8s-ha", StandardK8sConfig());

        machines.Should().HaveCount(7);
        machines.Should().Contain(m => m.Role == "k8s-load-balancer");
        machines.Where(m => m.Role == "k8s-control-plane").Should().HaveCount(3);
        machines.Where(m => m.Role == "k8s-worker").Should().HaveCount(3);

        var totalRam = machines.Sum(m => m.Memory);
        totalRam.Should().BeLessOrEqualTo(48 * 1024);   // fits the 48 GB budget
    }

    [Fact]
    public void composite_resolver_delegates_to_k8s_resolver_for_k8s_topology()
    {
        var k8sResolver = new K8sTopologyResolver();
        var standardResolver = new StandardTopologyResolver();
        var composite = new CompositeTopologyResolver(new[] { (IK8sTopologyResolver)k8sResolver }, standardResolver);

        var machines = composite.Resolve(Topology.K8sMulti, StandardK8sConfig());
        machines.Should().HaveCount(4);
    }

    [Fact]
    public void composite_resolver_falls_back_to_standard_for_compose_topology()
    {
        var k8sResolver = new K8sTopologyResolver();
        var standardResolver = new StandardTopologyResolver();
        var composite = new CompositeTopologyResolver(new[] { (IK8sTopologyResolver)k8sResolver }, standardResolver);

        var machines = composite.Resolve(Topology.Multi, StandardComposeConfig());
        machines.Should().HaveCount(4);   // gateway + platform + data + obs
    }
}

What this gives you that hand-edited Vagrantfiles don't

A hand-written Vagrantfile for a 7-VM HA Kubernetes cluster is ~150 lines of Ruby. Every line is a potential typo. Every IP is a string. Every memory size is duplicated between the file and the docs page. Every change to the topology requires editing the file by hand.

A typed topology resolver gives you, for the same surface area:

  • One config field to switch between topologies
  • Three deterministic methods that produce the VM list
  • Composite delegation that lets the plugin extend the resolver without modifying the core
  • Tests that lock the topology against the hardware budget
  • Mermaid diagrams that the documentation generator emits from the same projection

The bargain pays back the first time you switch from k8s-multi to k8s-ha and watch HomeLab provision three new control plane VMs and a load balancer with one config change.


⬇ Download