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"
}
};
}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);
}
}[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
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
}
}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.