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 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 plugin

The 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:

  1. A new role contract IHomeLabVerbGroup plus a [VerbGroup("k8s")] attribute on IHomeLabVerbCommand implementations
  2. The HomeLab CLI command builder scanning plugin assemblies for both
  3. The same architecture rules from homelab-docker Part 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.)

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

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"]
  }
}

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;
}

The K8sVerbGroup class:

[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;
    }
}

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;
    }
}

_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

No "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.Dsl could add homelab talos, Kafka.Dsl could add homelab 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.


⬇ Download