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 10: The Plugin System — Third-Party NuGets Without Forking

"If your extensibility story is 'fork the repo', you do not have an extensibility story."


Why

Every tool worth using ends up needing extensions. The list of "things HomeLab might need" is unbounded: a Cloudflare DNS provider, a Vault TLS provider, a FreeBSD jail host machine type, a Bitwarden secrets backend, a custom compose contributor for the team's internal service mesh, an Ops.Dsl sub-DSL for a domain-specific concern. The list grows the moment HomeLab ships. The list grows differently for every team.

There are exactly two ways to handle this:

  1. Fork the repo and add your code in-tree. Every team maintains their own fork. Upgrades are merge conflicts. Sharing is copy-paste. The community fragments.
  2. Define a plugin contract. Plugins ship as NuGets. The lib never recompiles. Upgrades are dotnet add package. Sharing is publishing. The community grows.

Option 2 is obviously correct, and obviously hard to do well. Most plugin systems fail in one of three ways: (a) the contract is too narrow (you can add a new DNS provider but not a new machine type), (b) the contract is too wide (the plugin is given the entire IServiceProvider and can do anything, including break the lib), or (c) the discovery mechanism is fragile (reflection over directories, hand-edited registries, assembly.LoadFrom from arbitrary paths).

The thesis of this part is: IHomeLabPlugin is one umbrella contract that lets a single NuGet contribute any combination of role-shaped sub-contracts. Discovery is assembly scanning of NuGets that declare a homelab.plugin.json manifest. Registration is [Injectable] on the plugin's contributors. The lib never recompiles. The plugin never reaches into internals it should not touch.


The shape

public interface IHomeLabPlugin
{
    string Name { get; }
    string Version { get; }
    string Description { get; }

    /// <summary>
    /// Called once at startup, after the plugin's [Injectable] services have been registered.
    /// The plugin may publish startup events, register Ops.Dsl concepts, etc.
    /// Should NOT do file I/O or network calls.
    /// </summary>
    void Initialize(IPluginContext context);
}

public interface IPluginContext
{
    IHomeLabEventBus Events { get; }
    IMetamodelRegistry Metamodel { get; }   // for Ops.Dsl concept registration
    IClock Clock { get; }
    IFileSystem FileSystem { get; }          // sandboxed to the lab's working dir
    ILogger Logger { get; }
}

The plugin itself is a thin coordinator. The actual work is done by contributors[Injectable] services that implement role-shaped interfaces:

// Role 1: Add a new machine type
public interface IMachineTypeContributor
{
    void Contribute(VosMachine machine);
}

// Role 2: Add packer build steps
public interface IPackerBundleContributor
{
    void Contribute(PackerBundle bundle);
}

// Role 3: Add compose services
public interface IComposeFileContributor
{
    void Contribute(ComposeFile compose);
}

// Role 4: Add Traefik routing
public interface ITraefikContributor
{
    void Contribute(TraefikDynamicConfig traefik);
}

// Role 5: Provide DNS
public interface IDnsProvider
{
    string Name { get; }
    Task<Result> AddAsync(string hostname, string ip, CancellationToken ct);
    Task<Result> RemoveAsync(string hostname, CancellationToken ct);
    Task<Result<IReadOnlyList<DnsEntry>>> ListAsync(CancellationToken ct);
}

// Role 6: Provide TLS certificates
public interface ITlsCertificateProvider
{
    string Name { get; }
    Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct);
    Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string domain, string[] sans, CancellationToken ct);
}

// Role 7: Provide a container engine
public interface IContainerEngine
{
    string Name { get; }
    Task<Result> RunComposeAsync(string composeFile, string projectName, CancellationToken ct);
    Task<Result> StopComposeAsync(string projectName, CancellationToken ct);
}

// Role 8: Add a secrets store backend
public interface ISecretStore
{
    string Name { get; }
    Task<Result<string>> ReadAsync(string key, CancellationToken ct);
    Task<Result> WriteAsync(string key, string value, CancellationToken ct);
}

// Role 9: Add a backup provider
public interface IBackupProvider
{
    string Name { get; }
    Task<Result<BackupId>> RunAsync(BackupSpec spec, CancellationToken ct);
    Task<Result> RestoreAsync(BackupId id, CancellationToken ct);
}

A plugin can implement zero or more of these. A "Cloudflare DNS plugin" implements IDnsProvider. A "FreeBSD jail host plugin" implements IPackerBundleContributor + IMachineTypeContributor. A "service mesh plugin" implements IComposeFileContributor + ITraefikContributor. A "GPU passthrough plugin" implements IMachineTypeContributor + IPackerBundleContributor + maybe IComposeFileContributor for nvidia-runtime.

The umbrella IHomeLabPlugin is the entry point — it gives the plugin a chance to do startup work, register Ops.Dsl concepts in the metamodel, publish a PluginLoaded event. The actual contribution happens through the role interfaces, which are picked up automatically by DI.


Plugin manifest

Every plugin NuGet ships a homelab.plugin.json in its package, used for discovery:

{
  "$schema": "https://frenchexdev.lab/schemas/homelab-plugin.schema.json",
  "name": "FrenchExDev.HomeLab.Plugin.Cloudflare",
  "version": "1.2.0",
  "description": "Cloudflare DNS provider for HomeLab",
  "homelabApiVersion": "^1.0",
  "entryPoint": "FrenchExDev.HomeLab.Plugin.Cloudflare.CloudflarePlugin",
  "provides": {
    "dnsProviders": ["cloudflare"]
  },
  "requires": {
    "config": ["cloudflare.api_token", "cloudflare.zone_id"]
  }
}

The manifest is the declarative face of the plugin. It tells HomeLab what the plugin provides, what it requires, and which API version it targets. Schema validation catches malformed manifests at load time. Version pinning (homelabApiVersion: ^1.0) prevents loading a plugin built against a future API.


Discovery

IPluginHost is the lib service that finds and loads plugins:

[Injectable(ServiceLifetime.Singleton)]
public sealed class PluginHost : IPluginHost
{
    private readonly IFileSystem _fs;
    private readonly IHomeLabEventBus _events;
    private readonly IPluginManifestLoader _manifests;
    private readonly IClock _clock;

    public async Task<Result<IReadOnlyList<LoadedPlugin>>> DiscoverAsync(
        DirectoryInfo nugetCacheDir,
        IEnumerable<string> requestedPluginIds,
        CancellationToken ct)
    {
        var loaded = new List<LoadedPlugin>();
        foreach (var id in requestedPluginIds)
        {
            var pluginDir = Path.Combine(nugetCacheDir.FullName, id.ToLowerInvariant());
            if (!_fs.Directory.Exists(pluginDir))
                return Result.Failure<IReadOnlyList<LoadedPlugin>>(
                    $"plugin not installed: {id}. Run `dotnet add package {id}`.");

            var manifestPath = Path.Combine(pluginDir, "homelab.plugin.json");
            if (!_fs.File.Exists(manifestPath))
                return Result.Failure<IReadOnlyList<LoadedPlugin>>(
                    $"plugin {id} has no homelab.plugin.json — not a HomeLab plugin");

            var manifestResult = await _manifests.LoadAsync(manifestPath, ct);
            if (manifestResult.IsFailure) return manifestResult.Map<IReadOnlyList<LoadedPlugin>>();

            var manifest = manifestResult.Value;
            if (!IsCompatible(manifest.HomelabApiVersion))
                return Result.Failure<IReadOnlyList<LoadedPlugin>>(
                    $"plugin {id} requires HomeLab API {manifest.HomelabApiVersion}, but this is {ApiVersion.Current}");

            var asm = LoadAssemblyForManifest(pluginDir, manifest);
            var pluginType = asm.GetType(manifest.EntryPoint)
                ?? throw new InvalidOperationException($"entry point {manifest.EntryPoint} not found in {asm.GetName().Name}");

            loaded.Add(new LoadedPlugin(manifest, pluginType, asm));
            await _events.PublishAsync(new PluginLoaded(manifest.Name, manifest.Version, _clock.UtcNow), ct);
        }
        return Result.Success<IReadOnlyList<LoadedPlugin>>(loaded);
    }
}

Discovery is explicit: the user lists the plugins they want in config-homelab.yaml (plugins:), and PluginHost loads exactly those, in that order, from the local NuGet cache. No directory scanning. No assembly.LoadFrom from random paths. No "if you put a DLL in this folder, it gets loaded".

Registration

After discovery, the loaded plugin assemblies are passed through the [Injectable] source generator's runtime helper, which scans them for [Injectable] types and registers them in the same IServiceCollection HomeLab itself uses:

public static IServiceCollection AddHomeLabPlugins(
    this IServiceCollection services,
    IReadOnlyList<LoadedPlugin> plugins)
{
    foreach (var plugin in plugins)
    {
        // Calls the [Injectable] runtime registration for the plugin's assembly
        InjectableServiceCollectionExtensions.AddFromAssembly(services, plugin.Assembly);
        // Register the plugin entry point itself
        services.AddSingleton(typeof(IHomeLabPlugin), plugin.PluginType);
    }
    return services;
}

After this call, the DI container has all the plugin's contributors. They are indistinguishable from in-tree contributors at injection time. IEnumerable<IDnsProvider> returns both the built-in HostsFileDnsProvider and the plugin's CloudflareDnsProvider. IEnumerable<IPackerBundleContributor> returns both the built-in Alpine and DockerHost contributors and any plugin-provided contributors.

Initialization

Once the container is built, the host calls Initialize on every loaded plugin once:

public void InitializeAll(IServiceProvider sp, IReadOnlyList<LoadedPlugin> plugins)
{
    var ctx = new PluginContext(
        Events:    sp.GetRequiredService<IHomeLabEventBus>(),
        Metamodel: sp.GetRequiredService<IMetamodelRegistry>(),
        Clock:     sp.GetRequiredService<IClock>(),
        FileSystem: new SandboxedFileSystem(sp.GetRequiredService<IFileSystem>(), allowedRoot: ".homelab/plugins"),
        Logger:    sp.GetRequiredService<ILoggerFactory>().CreateLogger("HomeLab.Plugin"));

    foreach (var plugin in plugins)
    {
        var instance = (IHomeLabPlugin)sp.GetRequiredService(plugin.PluginType);
        instance.Initialize(ctx);
    }
}

PluginContext is the only surface the plugin sees. It does not get the full IServiceProvider. It does not get raw file system access (the IFileSystem it gets is sandboxed to .homelab/plugins/<plugin-name>). It does not get access to private types in the lib. The plugin can publish events, register Ops.Dsl concepts in the shared metamodel, log, and read the clock. That is the contract.


A worked example: a Cloudflare DNS plugin

// FrenchExDev.HomeLab.Plugin.Cloudflare/CloudflarePlugin.cs

public sealed class CloudflarePlugin : IHomeLabPlugin
{
    public string Name => "FrenchExDev.HomeLab.Plugin.Cloudflare";
    public string Version => "1.2.0";
    public string Description => "Cloudflare DNS provider for HomeLab";

    public void Initialize(IPluginContext context)
    {
        context.Logger.LogInformation("Cloudflare DNS plugin {Version} initialized", Version);
        // Nothing else to do — the contributors below are auto-registered via [Injectable]
    }
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class CloudflareDnsProvider : IDnsProvider
{
    public string Name => "cloudflare";

    private readonly ICloudflareApi _api;
    private readonly IHomeLabEventBus _events;
    private readonly IClock _clock;

    public CloudflareDnsProvider(ICloudflareApi api, IHomeLabEventBus events, IClock clock)
    {
        _api = api;
        _events = events;
        _clock = clock;
    }

    public async Task<Result> AddAsync(string hostname, string ip, CancellationToken ct)
    {
        var result = await _api.CreateRecordAsync(hostname, ip, ct);
        if (result.IsFailure) return result;
        await _events.PublishAsync(new DnsEntryAdded(hostname, ip, "cloudflare", _clock.UtcNow), ct);
        return Result.Success();
    }

    // ...
}

That is the entire plugin. Two classes. The plugin entry point is essentially a stub (the Initialize method only logs). The work is done by CloudflareDnsProvider, which is [Injectable] and automatically picked up by HomeLab's DI when the plugin assembly is scanned.

Adding the plugin to a lab is one config line:

# config-homelab.yaml
plugins:
  - FrenchExDev.HomeLab.Plugin.Cloudflare

dns:
  provider: cloudflare      # ← uses the plugin

And one NuGet install:

dotnet add package FrenchExDev.HomeLab.Plugin.Cloudflare

That's it. No fork. No recompile of HomeLab. No registry to edit. The plugin is in the dependency graph; HomeLab discovers it; the contributors flow into DI; the user picks cloudflare as the DNS provider; everything just works.

Part 53 is the full end-to-end walkthrough of writing a plugin from scratch — including the manifest, the test harness, the publishing flow.


The test

[Fact]
public async Task plugin_with_dns_provider_is_picked_up_by_di()
{
    var services = new ServiceCollection().AddHomeLab();

    // Simulate plugin discovery: register the plugin's assembly directly
    var pluginAsm = typeof(CloudflareDnsProvider).Assembly;
    InjectableServiceCollectionExtensions.AddFromAssembly(services, pluginAsm);

    var sp = services.BuildServiceProvider();
    var providers = sp.GetServices<IDnsProvider>().ToList();

    providers.Should().Contain(p => p.Name == "cloudflare");
    providers.Should().Contain(p => p.Name == "hosts-file");      // built-in still present
}

[Fact]
public async Task plugin_with_incompatible_api_version_fails_to_load()
{
    var manifest = new PluginManifest(
        Name: "FrenchExDev.HomeLab.Plugin.FromTheFuture",
        Version: "1.0.0",
        HomelabApiVersion: "^99.0.0",
        EntryPoint: "x");
    var host = new PluginHost(/* ... */);

    var result = await host.LoadAsync(manifest, CancellationToken.None);

    result.IsFailure.Should().BeTrue();
    result.Errors.Should().Contain(e => e.Contains("99.0.0"));
}

[Fact]
public async Task plugin_load_publishes_PluginLoaded_event()
{
    var bus = new RecordingEventBus();
    var host = new PluginHost(/* with bus */);

    await host.DiscoverAsync(/* ... */);

    bus.Recorded.OfType<PluginLoaded>().Should().HaveCount(1);
}

What this gives you that bash doesn't

A bash script has no plugins. Extending a bash script means editing the bash script. There is no contract, no manifest, no version compatibility, no isolation, no test harness. There is the script.

A typed plugin system gives you, for the same surface area:

  • An umbrella contract (IHomeLabPlugin) with role-shaped sub-contracts
  • A manifest that tells HomeLab what the plugin provides and what API version it needs
  • API-version compatibility that fails fast on mismatch instead of crashing at runtime
  • Sandboxed file system access through IPluginContext, not raw File.WriteAllText
  • Auto-registration via [Injectable] — no central registry to edit
  • Test harness — plugins ship with their own xUnit tests, run by the consuming lab's CI
  • NuGet distribution — plugins are versioned, signed, and shareable

The bargain is the highest leverage of all the architectural decisions in this series. The first plugin you write costs about a day. The second plugin costs an hour, because the first one figured out the manifest schema, the test harness, the publishing flow. By the tenth plugin, the team has a culture of "things go in plugins", and the lib stops growing — it has reached its mature footprint, and everything new lives outside it.


⬇ Download