Part 11: The Plugin Manifest, the CLI Verb Plugin Surface, and the Two-NuGet Split
"A plugin can extend the lib. A plugin can extend the CLI. The K8s.Dsl plugin does both, with two NuGets, with zero changes to HomeLab core."
Why
We have spent ten parts designing the lib side of the K8s.Dsl plugin: the metamodel, the contributors, the topology resolver, the kubeconfig store, the secrets bridge. None of those things are user-facing on their own. The user-facing surface is the CLI: homelab k8s init, homelab k8s create, homelab k8s upgrade, homelab k8s argocd add-app. Those verbs do not exist in HomeLab core. They are added by the K8s.Dsl plugin.
This is the deepest form of plugin extensibility HomeLab supports. A plugin does not just contribute services and contributors — it can ship its own top-level CLI verb tree. After loading, homelab --help shows:
Usage: homelab [command]
Commands:
init Bootstrap a HomeLab project
validate Validate the configuration
packer Build VM images via Packer
box Manage Vagrant boxes
vos VM lifecycle (Vagrant orchestration)
compose Docker Compose stacks
dns DNS management
tls TLS certificate generation
gitlab GitLab Omnibus configuration
k8s Kubernetes clusters and workloads ← from the K8s.Dsl pluginUsage: homelab [command]
Commands:
init Bootstrap a HomeLab project
validate Validate the configuration
packer Build VM images via Packer
box Manage Vagrant boxes
vos VM lifecycle (Vagrant orchestration)
compose Docker Compose stacks
dns DNS management
tls TLS certificate generation
gitlab GitLab Omnibus configuration
k8s Kubernetes clusters and workloads ← from the K8s.Dsl pluginThe user does not see "from a plugin" anywhere. The k8s group is a sibling of every other group. This is invisible pluggability, and it requires three things:
- A new role contract
IHomeLabVerbGroupplus a[VerbGroup("k8s")]attribute onIHomeLabVerbCommandimplementations - The HomeLab CLI command builder scanning plugin assemblies for both
- The same architecture rules from
homelab-dockerPart 03 applied to plugin assemblies via the architecture test
The thesis: K8s.Dsl ships as two NuGets — K8sDsl (the lib, no System.CommandLine) and K8sDsl.Cli (the verb shells only) — and the HomeLab core is extended with the new IHomeLabVerbGroup role and the assembly scan that finds plugin verbs. The architecture is enforced by tests that scan both core and plugin assemblies.
The two NuGets
FrenchExDev.HomeLab.Plugin.K8sDsl/ (the Lib NuGet)
├─ K8sDsl.csproj
├─ homelab.plugin.json (manifest, see below)
├─ K8sDslPlugin.cs (the IHomeLabPlugin entry point)
├─ Concepts/ (12 [MetaConcept] classes from Part 06)
├─ Contributors/ (IK8sManifestContributor, IHelmReleaseContributor)
├─ TopologyResolver/ (K8sTopologyResolver from Part 08)
├─ Kubeconfig/ (IKubeconfigStore from Part 09)
├─ Secrets/ (ExternalSecrets bridge from Part 10)
├─ Distribution/ (KubeadmClusterDistribution, K3sClusterDistribution)
├─ Wrappers/ (KubectlClient, HelmClient, KubeadmClient — all [BinaryWrapper])
├─ Bundle/ (Kubernetes.Bundle types)
├─ Handlers/ (every IRequestHandler — the lib side of the verbs)
└─ Stages/ (K8sGenerateStage, K8sApplyStage, K8sVerifyStage)
FrenchExDev.HomeLab.Plugin.K8sDsl.Cli/ (the Cli NuGet)
├─ K8sDsl.Cli.csproj
├─ K8sVerbGroup.cs (IHomeLabVerbGroup implementation)
├─ Verbs/
│ ├─ K8sInitCommand.cs (homelab k8s init)
│ ├─ K8sCreateCommand.cs
│ ├─ K8sDestroyCommand.cs
│ ├─ K8sNodeCommand.cs (parent for k8s node add / drain)
│ ├─ K8sNodeAddCommand.cs
│ ├─ K8sNodeDrainCommand.cs
│ ├─ K8sApplyCommand.cs
│ ├─ K8sUseContextCommand.cs
│ ├─ K8sKubectlCommand.cs (passes through to kubectl with the right context)
│ ├─ K8sHelmCommand.cs (similar — passthrough to helm)
│ ├─ K8sUpgradeCommand.cs
│ ├─ K8sBackupCommand.cs
│ ├─ K8sRestoreCommand.cs
│ ├─ K8sStatusCommand.cs
│ ├─ K8sLogsCommand.cs
│ └─ K8sArgoCdCommand.cs (parent for k8s argocd init / add-app / add-env)
└─ Renderers/ (IK8sConsoleRenderer for status, plan, etc.)FrenchExDev.HomeLab.Plugin.K8sDsl/ (the Lib NuGet)
├─ K8sDsl.csproj
├─ homelab.plugin.json (manifest, see below)
├─ K8sDslPlugin.cs (the IHomeLabPlugin entry point)
├─ Concepts/ (12 [MetaConcept] classes from Part 06)
├─ Contributors/ (IK8sManifestContributor, IHelmReleaseContributor)
├─ TopologyResolver/ (K8sTopologyResolver from Part 08)
├─ Kubeconfig/ (IKubeconfigStore from Part 09)
├─ Secrets/ (ExternalSecrets bridge from Part 10)
├─ Distribution/ (KubeadmClusterDistribution, K3sClusterDistribution)
├─ Wrappers/ (KubectlClient, HelmClient, KubeadmClient — all [BinaryWrapper])
├─ Bundle/ (Kubernetes.Bundle types)
├─ Handlers/ (every IRequestHandler — the lib side of the verbs)
└─ Stages/ (K8sGenerateStage, K8sApplyStage, K8sVerifyStage)
FrenchExDev.HomeLab.Plugin.K8sDsl.Cli/ (the Cli NuGet)
├─ K8sDsl.Cli.csproj
├─ K8sVerbGroup.cs (IHomeLabVerbGroup implementation)
├─ Verbs/
│ ├─ K8sInitCommand.cs (homelab k8s init)
│ ├─ K8sCreateCommand.cs
│ ├─ K8sDestroyCommand.cs
│ ├─ K8sNodeCommand.cs (parent for k8s node add / drain)
│ ├─ K8sNodeAddCommand.cs
│ ├─ K8sNodeDrainCommand.cs
│ ├─ K8sApplyCommand.cs
│ ├─ K8sUseContextCommand.cs
│ ├─ K8sKubectlCommand.cs (passes through to kubectl with the right context)
│ ├─ K8sHelmCommand.cs (similar — passthrough to helm)
│ ├─ K8sUpgradeCommand.cs
│ ├─ K8sBackupCommand.cs
│ ├─ K8sRestoreCommand.cs
│ ├─ K8sStatusCommand.cs
│ ├─ K8sLogsCommand.cs
│ └─ K8sArgoCdCommand.cs (parent for k8s argocd init / add-app / add-env)
└─ Renderers/ (IK8sConsoleRenderer for status, plan, etc.)The Lib NuGet has zero references to System.CommandLine. The Cli NuGet has zero references to System.IO (other than Path.Combine for argument paths) — every file operation is delegated to the Lib.
The architecture test that enforces this lives in K8sDsl.Lib.Tests and runs against both assemblies:
[Fact]
public void k8sdsl_lib_must_not_reference_System_CommandLine()
{
typeof(K8sDslPlugin).Assembly
.GetReferencedAssemblies()
.Should().NotContain(a => a.Name == "System.CommandLine");
}
[Fact]
public void k8sdsl_cli_verbs_must_not_perform_io()
{
var verbAssembly = typeof(K8sVerbGroup).Assembly;
var verbs = verbAssembly.GetTypes()
.Where(t => typeof(IHomeLabVerbCommand).IsAssignableFrom(t) && !t.IsInterface);
foreach (var verb in verbs)
{
var calls = MethodCallScanner.Scan(verb, new[]
{
typeof(File), typeof(Directory), typeof(Process), typeof(Path)
});
// Path is allowed for combining argument paths but not for file operations
calls.Where(c => c.TargetType != typeof(Path)).Should().BeEmpty(
$"{verb.Name} must delegate to the lib (no I/O in verbs)");
}
}[Fact]
public void k8sdsl_lib_must_not_reference_System_CommandLine()
{
typeof(K8sDslPlugin).Assembly
.GetReferencedAssemblies()
.Should().NotContain(a => a.Name == "System.CommandLine");
}
[Fact]
public void k8sdsl_cli_verbs_must_not_perform_io()
{
var verbAssembly = typeof(K8sVerbGroup).Assembly;
var verbs = verbAssembly.GetTypes()
.Where(t => typeof(IHomeLabVerbCommand).IsAssignableFrom(t) && !t.IsInterface);
foreach (var verb in verbs)
{
var calls = MethodCallScanner.Scan(verb, new[]
{
typeof(File), typeof(Directory), typeof(Process), typeof(Path)
});
// Path is allowed for combining argument paths but not for file operations
calls.Where(c => c.TargetType != typeof(Path)).Should().BeEmpty(
$"{verb.Name} must delegate to the lib (no I/O in verbs)");
}
}These tests are the architectural firewall. They run on every CI build of K8sDsl and prevent the plugin from drifting into the same anti-patterns the core forbids.
The plugin manifest
The manifest tells HomeLab how to load the plugin. K8s.Dsl declares both assemblies:
{
"$schema": "https://frenchexdev.lab/schemas/homelab-plugin.schema.json",
"name": "FrenchExDev.HomeLab.Plugin.K8sDsl",
"version": "1.0.0",
"description": "K8s.Dsl: real Kubernetes (kubeadm or k3s) on Vagrant VMs as a HomeLab plugin",
"homelabApiVersion": "^1.0",
"assemblies": {
"lib": "FrenchExDev.HomeLab.Plugin.K8sDsl.dll",
"cli": "FrenchExDev.HomeLab.Plugin.K8sDsl.Cli.dll"
},
"entryPoint": "FrenchExDev.HomeLab.Plugin.K8sDsl.K8sDslPlugin",
"verbGroups": ["k8s"],
"provides": {
"manifestContributors": ["FrenchExDev.HomeLab.Plugin.K8sDsl.Contributors.*"],
"helmReleaseContributors": ["FrenchExDev.HomeLab.Plugin.K8sDsl.HelmContributors.*"],
"clusterDistributions": ["kubeadm", "k3s"],
"topologyResolvers": ["k8s-single", "k8s-multi", "k8s-ha"],
"kubeconfigStores": ["merged", "isolated"]
},
"requires": {
"config": ["k8s.distribution", "k8s.version"],
"secrets": ["GITLAB_ROOT_PASSWORD"],
"binaries": ["kubectl", "helm", "kubeadm-or-k3s"]
}
}{
"$schema": "https://frenchexdev.lab/schemas/homelab-plugin.schema.json",
"name": "FrenchExDev.HomeLab.Plugin.K8sDsl",
"version": "1.0.0",
"description": "K8s.Dsl: real Kubernetes (kubeadm or k3s) on Vagrant VMs as a HomeLab plugin",
"homelabApiVersion": "^1.0",
"assemblies": {
"lib": "FrenchExDev.HomeLab.Plugin.K8sDsl.dll",
"cli": "FrenchExDev.HomeLab.Plugin.K8sDsl.Cli.dll"
},
"entryPoint": "FrenchExDev.HomeLab.Plugin.K8sDsl.K8sDslPlugin",
"verbGroups": ["k8s"],
"provides": {
"manifestContributors": ["FrenchExDev.HomeLab.Plugin.K8sDsl.Contributors.*"],
"helmReleaseContributors": ["FrenchExDev.HomeLab.Plugin.K8sDsl.HelmContributors.*"],
"clusterDistributions": ["kubeadm", "k3s"],
"topologyResolvers": ["k8s-single", "k8s-multi", "k8s-ha"],
"kubeconfigStores": ["merged", "isolated"]
},
"requires": {
"config": ["k8s.distribution", "k8s.version"],
"secrets": ["GITLAB_ROOT_PASSWORD"],
"binaries": ["kubectl", "helm", "kubeadm-or-k3s"]
}
}The assemblies block is new (compared to homelab-docker Part 10's manifest). HomeLab core's IPluginHost reads it and loads both DLLs. The entryPoint is in the Lib. The verbGroups field tells HomeLab to look in the Cli assembly for verb groups named k8s.
The HomeLab core's plugin manifest schema is extended to support the assemblies block (a backward-compatible change — old plugins with a single assembly field still load). The schema validation rejects manifests where both assembly and assemblies are present.
IHomeLabVerbGroup and [VerbGroup]
Two new types in the HomeLab core (small additions, ~50 lines total):
namespace FrenchExDev.HomeLab.PluginSdk.Cli;
public interface IHomeLabVerbGroup
{
string Name { get; }
string Description { get; }
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class VerbGroupAttribute : Attribute
{
public string GroupName { get; }
public VerbGroupAttribute(string groupName) => GroupName = groupName;
}namespace FrenchExDev.HomeLab.PluginSdk.Cli;
public interface IHomeLabVerbGroup
{
string Name { get; }
string Description { get; }
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class VerbGroupAttribute : Attribute
{
public string GroupName { get; }
public VerbGroupAttribute(string groupName) => GroupName = groupName;
}The K8sVerbGroup class:
[Injectable(ServiceLifetime.Singleton)]
public sealed class K8sVerbGroup : IHomeLabVerbGroup
{
public string Name => "k8s";
public string Description => "Kubernetes clusters and workloads (kubeadm, k3s)";
}[Injectable(ServiceLifetime.Singleton)]
public sealed class K8sVerbGroup : IHomeLabVerbGroup
{
public string Name => "k8s";
public string Description => "Kubernetes clusters and workloads (kubeadm, k3s)";
}That's it. Three lines. The class exists to register the group name and description so the help text knows what to show. The actual sub-verbs hang off it via the [VerbGroup("k8s")] attribute on each IHomeLabVerbCommand implementation:
[Injectable(ServiceLifetime.Singleton)]
[VerbGroup("k8s")]
public sealed class K8sInitCommand : IHomeLabVerbCommand
{
private readonly IMediator _mediator;
private readonly IHomeLabConsole _console;
public K8sInitCommand(IMediator mediator, IHomeLabConsole console)
{
_mediator = mediator;
_console = console;
}
public Command Build()
{
var name = new Argument<string>("name");
var distribution = new Option<string>("--distribution", () => "kubeadm");
var topology = new Option<string>("--topology", () => "k8s-multi");
var version = new Option<string>("--version", () => "v1.31.4");
var cmd = new Command("init", "Initialize a new Kubernetes cluster project");
cmd.AddArgument(name);
cmd.AddOption(distribution);
cmd.AddOption(topology);
cmd.AddOption(version);
cmd.SetHandler(async (string n, string d, string t, string v) =>
{
var result = await _mediator.SendAsync(
new K8sInitRequest(n, d, t, v),
CancellationToken.None);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name, distribution, topology, version);
return cmd;
}
}[Injectable(ServiceLifetime.Singleton)]
[VerbGroup("k8s")]
public sealed class K8sInitCommand : IHomeLabVerbCommand
{
private readonly IMediator _mediator;
private readonly IHomeLabConsole _console;
public K8sInitCommand(IMediator mediator, IHomeLabConsole console)
{
_mediator = mediator;
_console = console;
}
public Command Build()
{
var name = new Argument<string>("name");
var distribution = new Option<string>("--distribution", () => "kubeadm");
var topology = new Option<string>("--topology", () => "k8s-multi");
var version = new Option<string>("--version", () => "v1.31.4");
var cmd = new Command("init", "Initialize a new Kubernetes cluster project");
cmd.AddArgument(name);
cmd.AddOption(distribution);
cmd.AddOption(topology);
cmd.AddOption(version);
cmd.SetHandler(async (string n, string d, string t, string v) =>
{
var result = await _mediator.SendAsync(
new K8sInitRequest(n, d, t, v),
CancellationToken.None);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name, distribution, topology, version);
return cmd;
}
}Five-line shell pattern from homelab-docker Part 03, with the [VerbGroup("k8s")] attribute attaching it to the parent group.
The HomeLab CLI command builder extension
The HomeLab core's command builder needs one small change to find verb groups in plugin assemblies. Here is the relevant part of HomeLabRootCommand.Build():
[Injectable(ServiceLifetime.Singleton)]
public sealed class HomeLabRootCommand
{
private readonly IEnumerable<IHomeLabVerbCommand> _coreVerbs;
private readonly IEnumerable<IHomeLabVerbGroup> _verbGroups;
private readonly IServiceProvider _sp;
public RootCommand Build()
{
var root = new RootCommand("HomeLab — local infrastructure orchestrator");
// 1. Add core verbs that are not in a group
foreach (var verb in _coreVerbs)
{
var attr = verb.GetType().GetCustomAttribute<VerbGroupAttribute>();
if (attr is null) root.Add(verb.Build());
}
// 2. Add verb groups (including plugin-supplied)
foreach (var group in _verbGroups)
{
var groupCommand = new Command(group.Name, group.Description);
// Find every verb decorated with [VerbGroup(group.Name)]
var groupVerbs = _coreVerbs
.Where(v => v.GetType().GetCustomAttribute<VerbGroupAttribute>()?.GroupName == group.Name);
foreach (var verb in groupVerbs)
groupCommand.Add(verb.Build());
root.Add(groupCommand);
}
return root;
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class HomeLabRootCommand
{
private readonly IEnumerable<IHomeLabVerbCommand> _coreVerbs;
private readonly IEnumerable<IHomeLabVerbGroup> _verbGroups;
private readonly IServiceProvider _sp;
public RootCommand Build()
{
var root = new RootCommand("HomeLab — local infrastructure orchestrator");
// 1. Add core verbs that are not in a group
foreach (var verb in _coreVerbs)
{
var attr = verb.GetType().GetCustomAttribute<VerbGroupAttribute>();
if (attr is null) root.Add(verb.Build());
}
// 2. Add verb groups (including plugin-supplied)
foreach (var group in _verbGroups)
{
var groupCommand = new Command(group.Name, group.Description);
// Find every verb decorated with [VerbGroup(group.Name)]
var groupVerbs = _coreVerbs
.Where(v => v.GetType().GetCustomAttribute<VerbGroupAttribute>()?.GroupName == group.Name);
foreach (var verb in groupVerbs)
groupCommand.Add(verb.Build());
root.Add(groupCommand);
}
return root;
}
}_coreVerbs is IEnumerable<IHomeLabVerbCommand> — DI returns every [Injectable] instance, including those discovered in plugin assemblies via IPluginHost.AddFromAssembly. _verbGroups is IEnumerable<IHomeLabVerbGroup> — same story.
The builder does not know which verbs come from the core and which come from plugins. From its perspective they are all just instances injected by DI.
This is what makes pluggability invisible at the user surface: the help text shows the k8s group with no marker, the verbs work exactly like every other verb, and the user has no way to tell whether a verb is core or plugin (and no reason to care).
The 14 K8s verbs
| Verb | Purpose | Lib handler |
|---|---|---|
homelab k8s init <name> |
Initialize a new k8s cluster project | K8sInitRequestHandler |
homelab k8s create |
Provision the cluster (Packer + Vagrant + kubeadm/k3s init) | K8sCreateRequestHandler |
homelab k8s destroy |
Tear down the cluster (Vagrant destroy + kubeconfig cleanup) | K8sDestroyRequestHandler |
homelab k8s node add <name> |
Add a worker node | K8sNodeAddRequestHandler |
homelab k8s node drain <name> |
Drain a node | K8sNodeDrainRequestHandler |
homelab k8s apply |
Apply the K8s.Dsl bundle (manifests + Helm releases) | K8sApplyRequestHandler |
homelab k8s use-context <name> |
Switch active kubectl context | K8sUseContextRequestHandler |
homelab k8s kubectl ... |
Passthrough to kubectl with the right context | (passthrough) |
homelab k8s helm ... |
Passthrough to helm with the right context | (passthrough) |
homelab k8s upgrade --to <version> |
Walk the cluster upgrade saga | K8sUpgradeRequestHandler |
homelab k8s backup |
Trigger a Velero backup | K8sBackupRequestHandler |
homelab k8s restore <id> |
Restore a Velero backup | K8sRestoreRequestHandler |
homelab k8s status |
Cluster + workload health summary | K8sStatusRequestHandler |
homelab k8s logs <pod> |
Stream logs via Loki bridge | K8sLogsRequestHandler |
Plus the ArgoCD sub-tree from Part 33:
| Verb | Purpose |
|---|---|
homelab k8s argocd init <repo> |
Create the GitOps repository in DevLab's GitLab |
homelab k8s argocd add-app <name> |
Add an Application to the GitOps repo |
homelab k8s argocd add-env <env> |
Add an environment overlay |
homelab k8s argocd sync <name> |
Force ArgoCD to sync a specific application |
Eighteen verbs total. Each one is a five-line shell in the Cli NuGet, delegating to a handler in the Lib NuGet, returning Result<T>, publishing events on success/failure.
What homelab --help shows after loading
$ homelab --help
Usage: homelab [command]
Commands:
init Bootstrap a HomeLab project
validate Validate the configuration
packer Build VM images via Packer
box Manage Vagrant boxes
vos VM lifecycle (Vagrant orchestration)
compose Docker Compose stacks
dns DNS management
tls TLS certificate generation
gitlab GitLab Omnibus configuration
k8s Kubernetes clusters and workloads (kubeadm, k3s)
$ homelab k8s --help
Usage: homelab k8s [command]
Commands:
init Initialize a new Kubernetes cluster project
create Provision the cluster
destroy Tear down the cluster
node Manage individual nodes
apply Apply the K8s.Dsl bundle
use-context Switch active kubectl context
kubectl Passthrough to kubectl with the right context
helm Passthrough to helm with the right context
upgrade Walk the cluster upgrade saga
backup Trigger a Velero backup
restore Restore a Velero backup
status Cluster and workload health summary
logs Stream logs via the Loki bridge
argocd ArgoCD and GitOps repository tooling$ homelab --help
Usage: homelab [command]
Commands:
init Bootstrap a HomeLab project
validate Validate the configuration
packer Build VM images via Packer
box Manage Vagrant boxes
vos VM lifecycle (Vagrant orchestration)
compose Docker Compose stacks
dns DNS management
tls TLS certificate generation
gitlab GitLab Omnibus configuration
k8s Kubernetes clusters and workloads (kubeadm, k3s)
$ homelab k8s --help
Usage: homelab k8s [command]
Commands:
init Initialize a new Kubernetes cluster project
create Provision the cluster
destroy Tear down the cluster
node Manage individual nodes
apply Apply the K8s.Dsl bundle
use-context Switch active kubectl context
kubectl Passthrough to kubectl with the right context
helm Passthrough to helm with the right context
upgrade Walk the cluster upgrade saga
backup Trigger a Velero backup
restore Restore a Velero backup
status Cluster and workload health summary
logs Stream logs via the Loki bridge
argocd ArgoCD and GitOps repository toolingNo "plugin" marker anywhere. The k8s group is indistinguishable from the built-ins, because at the architecture level, it is indistinguishable.
What this gives you that a fork-and-modify would
The alternative to plugin CLI extensibility is forking HomeLab and adding the verbs in-tree. Forks are bad: they diverge, they slow upgrades, they fragment the community, they prevent third-party contribution.
Plugin CLI extensibility via IHomeLabVerbGroup and [VerbGroup] gives you, for the same surface area:
- Two-NuGet split mirroring HomeLab's own thin-CLI / fat-lib boundary
- Auto-discovery of plugin verbs by assembly scanning
- Architecture tests that scan plugin assemblies for forbidden I/O
- Invisible pluggability — the user does not see the seam
- Multiple plugins can extend the CLI without conflicting (
Talos.Dslcould addhomelab talos,Kafka.Dslcould addhomelab kafka)
The bargain pays back the first time a third-party ships a plugin that adds a new verb tree to HomeLab without forking the repo, and HomeLab users install it via dotnet add package without knowing the difference.
End of Act II
We have now defined the entire K8s.Dsl plugin architecture: the M3 concepts, the contributor roles, the topology resolver, the kubeconfig store, the secrets bridge, the two-NuGet split, the CLI verb plugin surface, and the manifest. With this in hand, we can build the actual cluster.
Act III drops one level: building the real Kubernetes cluster on Vagrant VMs. Distribution choice, the Packer node image, kubeadm bootstrap, kubeadm join, CNI choice, CSI choice, ingress, cert-manager, external-dns, metrics-server. Ten parts of cluster guts.
Cross-links
- Part 06: The K8s.Dsl Spec
- Part 07: IK8sManifestContributor and IHelmReleaseContributor
- Part 09: Kubeconfig Management
- Part 12: Choosing the Distribution
- Part 33: ArgoCD and the GitOps Repo Generator
- HomeLab Docker — Part 03: Thin CLI, Fat Lib
- HomeLab Docker — Part 10: The Plugin System
- HomeLab Docker — Part 53: Writing a HomeLab Plugin