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 54: Extending Ops.Dsl from a Plugin

"The plugin contract for Ops.Dsl is one attribute and one companion class. After that, every analyzer in HomeLab knows about your concept."


Why

Part 53 showed how to ship a plugin that adds a new contributor (a DNS provider). This part shows the harder case: a plugin that adds a whole new sub-DSL to Ops.Dsl. Specifically, we will design Ops.HomeRouter — a hypothetical sub-DSL for managing the team's home router configuration (port forwards, firewall rules, VLANs).

The thesis of this part is: adding a new sub-DSL to Ops.Dsl is one new attribute class with [MetaConcept], one new companion class, and (optionally) some [MetaConstraint] validators. The plugin ships them in a NuGet. HomeLab's MetamodelRegistry discovers them at startup, and every analyzer that walks the metamodel sees the new concepts as first-class citizens.


The shape

Ops.HomeRouter defines four concepts:

  • HomeRouterDeviceAttribute — declares that a class represents a router device (e.g. UniFi USG, OPNsense, Mikrotik).
  • PortForwardAttribute — declares a port forwarding rule.
  • FirewallRuleAttribute — declares a firewall rule (allow/deny).
  • VlanAttribute — declares a VLAN.

Each one is an attribute decorated with [MetaConcept] and paired with a companion class.


The attribute definitions

// FrenchExDev.HomeLab.Plugin.HomeRouter — the plugin assembly

using FrenchExDev.Net.Dsl;

namespace FrenchExDev.HomeLab.Plugin.HomeRouter;

// 1. The router device concept
public sealed class HomeRouterDeviceConcept : MetaConcept
{
    public override string Name => "HomeRouterDevice";
    public override Type AttributeType => typeof(HomeRouterDeviceAttribute);
}

[MetaConcept(typeof(HomeRouterDeviceConcept))]
[AttributeUsage(AttributeTargets.Class)]
public sealed class HomeRouterDeviceAttribute : Attribute
{
    [MetaProperty("Vendor", "string", Required = true)]
    public string Vendor { get; }              // "unifi", "opnsense", "mikrotik"

    [MetaProperty("Model", "string")]
    public string? Model { get; set; }

    [MetaProperty("ManagementUrl", "string", Required = true)]
    public string ManagementUrl { get; }

    public HomeRouterDeviceAttribute(string vendor, string managementUrl)
    {
        Vendor = vendor;
        ManagementUrl = managementUrl;
    }
}

// 2. Port forward
public sealed class PortForwardConcept : MetaConcept
{
    public override string Name => "PortForward";
    public override Type AttributeType => typeof(PortForwardAttribute);
}

[MetaConcept(typeof(PortForwardConcept))]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
[MetaConstraint("ValidPortRange", nameof(ValidatePortRange),
    Message = "External and internal ports must be in [1, 65535]")]
public sealed class PortForwardAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("ExternalPort", "int", Required = true)]
    public int ExternalPort { get; }

    [MetaProperty("InternalIp", "string", Required = true)]
    public string InternalIp { get; }

    [MetaProperty("InternalPort", "int", Required = true)]
    public int InternalPort { get; }

    [MetaProperty("Protocol", "string")]
    public string Protocol { get; set; } = "tcp";

    public PortForwardAttribute(string name, int externalPort, string internalIp, int internalPort)
    {
        Name = name;
        ExternalPort = externalPort;
        InternalIp = internalIp;
        InternalPort = internalPort;
    }

    public static ConstraintResult ValidatePortRange(ConceptValidationContext ctx)
    {
        var ext = (int)ctx.GetProperty("ExternalPort")!;
        var intp = (int)ctx.GetProperty("InternalPort")!;
        if (ext < 1 || ext > 65535) return ConstraintResult.Failed($"ExternalPort {ext} out of range");
        if (intp < 1 || intp > 65535) return ConstraintResult.Failed($"InternalPort {intp} out of range");
        return ConstraintResult.Satisfied();
    }
}

// 3. Firewall rule
public sealed class FirewallRuleConcept : MetaConcept
{
    public override string Name => "FirewallRule";
    public override Type AttributeType => typeof(FirewallRuleAttribute);
}

[MetaConcept(typeof(FirewallRuleConcept))]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class FirewallRuleAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("Source", "string", Required = true)]
    public string Source { get; }              // CIDR or "any"

    [MetaProperty("Destination", "string", Required = true)]
    public string Destination { get; }         // CIDR or "any"

    [MetaProperty("Action", "string", Required = true)]
    public string Action { get; }              // "allow" | "deny"

    [MetaProperty("Protocol", "string")]
    public string Protocol { get; set; } = "any";

    [MetaProperty("Port", "int")]
    public int? Port { get; set; }

    public FirewallRuleAttribute(string name, string source, string destination, string action)
    {
        Name = name;
        Source = source;
        Destination = destination;
        Action = action;
    }
}

// 4. VLAN
public sealed class VlanConcept : MetaConcept
{
    public override string Name => "Vlan";
    public override Type AttributeType => typeof(VlanAttribute);
}

[MetaConcept(typeof(VlanConcept))]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class VlanAttribute : Attribute
{
    [MetaProperty("Id", "int", Required = true)]
    public int Id { get; }

    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("Subnet", "string", Required = true)]
    public string Subnet { get; }

    public VlanAttribute(int id, string name, string subnet)
    {
        Id = id;
        Name = name;
        Subnet = subnet;
    }
}

Four attribute/concept pairs. Each one follows the same pattern: a companion class extending MetaConcept, an attribute class with [MetaConcept], properties decorated with [MetaProperty], and (optionally) static methods decorated as [MetaConstraint].


A user declaration

The user (in their HomeLab project) declares their router config like this:

using FrenchExDev.HomeLab.Plugin.HomeRouter;

[HomeRouterDevice(vendor: "unifi", managementUrl: "https://192.168.1.1")]

[Vlan(id: 10, name: "trusted",   subnet: "192.168.10.0/24")]
[Vlan(id: 20, name: "untrusted", subnet: "192.168.20.0/24")]
[Vlan(id: 30, name: "iot",       subnet: "192.168.30.0/24")]

[PortForward(name: "gitlab-https",
             externalPort: 443,
             internalIp: "192.168.10.10",
             internalPort: 443,
             Protocol = "tcp")]

[FirewallRule(name: "block-iot-from-trusted",
              source: "192.168.30.0/24",
              destination: "192.168.10.0/24",
              action: "deny")]

[FirewallRule(name: "allow-trusted-egress",
              source: "192.168.10.0/24",
              destination: "any",
              action: "allow")]

public sealed class FrenchExDevHomeRouter { }

The user declares their router as a single C# class with attributes. The compiler validates the [MetaProperty] constraints. The [MetaConstraint] validators run at design time via MetaConstraintRunner (from the Dsl.Design project) — invalid port numbers fail at the IDE's hover-error level.


Generation: turning declarations into router config

The plugin ships a generator that walks every class with [HomeRouterDevice] and emits the router-vendor-specific config. For UniFi, that is a JSON file consumed by unifi-cli. For OPNsense, it is an XML file uploaded via the OPNsense API. For Mikrotik, it is a series of /ip firewall ... commands sent over SSH.

[Injectable(ServiceLifetime.Singleton)]
public sealed class UnifiHomeRouterGenerator : IHomeRouterGenerator
{
    public string Vendor => "unifi";

    public Result<string> Generate(HomeRouterModel model)
    {
        var json = new
        {
            networks = model.Vlans.Select(v => new { id = v.Id, name = v.Name, subnet = v.Subnet }),
            port_forwards = model.PortForwards.Select(pf => new
            {
                name = pf.Name,
                src_port = pf.ExternalPort,
                dst_address = pf.InternalIp,
                dst_port = pf.InternalPort,
                proto = pf.Protocol
            }),
            firewall_rules = model.FirewallRules.Select(r => new
            {
                name = r.Name,
                src = r.Source,
                dst = r.Destination,
                action = r.Action,
                proto = r.Protocol,
                port = r.Port
            })
        };

        return Result.Success(JsonSerializer.Serialize(json, new JsonSerializerOptions { WriteIndented = true }));
    }
}

The generator is [Injectable]. The plugin can ship multiple generators (one per vendor) and pick the right one based on the [HomeRouterDevice] Vendor property.


Wiring into HomeLab

The plugin's entry point registers its concepts with the metamodel registry:

public sealed class HomeRouterPlugin : IHomeLabPlugin
{
    public string Name => "FrenchExDev.HomeLab.Plugin.HomeRouter";
    public string Version => "1.0.0";
    public string Description => "Ops.HomeRouter sub-DSL: declare your home router config in C#";

    public void Initialize(IPluginContext context)
    {
        // Register concepts in the shared metamodel
        context.Metamodel.Register(new HomeRouterDeviceConcept());
        context.Metamodel.Register(new PortForwardConcept());
        context.Metamodel.Register(new FirewallRuleConcept());
        context.Metamodel.Register(new VlanConcept());

        context.Logger.LogInformation("Ops.HomeRouter plugin {Version} registered 4 concepts", Version);
    }
}

After Initialize, the MetamodelRegistry knows about HomeRouterDevice, PortForward, FirewallRule, and Vlan. Any analyzer that walks the registry — the validator, the documentation generator, the cross-DSL link analyzer — sees them as first-class concepts indistinguishable from Ops.Infrastructure.ContainerSpec or Ops.Networking.IngressRule.


Cross-DSL validation comes for free

Because the new concepts are in the same registry as the built-ins, HomeLab's existing analyzers automatically validate them. For example, the cross-DSL link analyzer can check that a [PortForward]'s InternalIp matches a VM IP that the topology resolver assigned:

[Injectable(ServiceLifetime.Singleton)]
public sealed class PortForwardTargetExistsValidator : IHomeLabConfigValidator
{
    public Result Validate(HomeLabConfig config)
    {
        var portForwards = _metamodel.GetInstancesOf<PortForwardConcept>();
        var machineIps = config.Machines.SelectMany(m => m.Networks.Select(n => n.Ip)).ToHashSet();

        var errors = new List<string>();
        foreach (var pf in portForwards)
        {
            if (!machineIps.Contains(pf.GetProperty<string>("InternalIp")!))
                errors.Add($"PortForward '{pf.GetProperty<string>("Name")}' targets {pf.GetProperty<string>("InternalIp")}, which is not a VM in the topology");
        }

        return errors.Count == 0 ? Result.Success() : Result.Failure(string.Join("\n", errors));
    }
}

The validator did not exist before the plugin was loaded, but it works against the plugin's concepts because the metamodel is shared. The plugin author did not need to write a "register a validator with HomeLab" hook — [Injectable] did it.


The test

public sealed class HomeRouterPluginTests
{
    [Fact]
    public void plugin_initialization_registers_four_concepts()
    {
        var registry = new InMemoryMetamodelRegistry();
        var ctx = new TestPluginContext(metamodel: registry);
        new HomeRouterPlugin().Initialize(ctx);

        registry.GetByName("HomeRouterDevice").Should().NotBeNull();
        registry.GetByName("PortForward").Should().NotBeNull();
        registry.GetByName("FirewallRule").Should().NotBeNull();
        registry.GetByName("Vlan").Should().NotBeNull();
    }

    [Fact]
    public void port_forward_with_invalid_external_port_fails_constraint()
    {
        var ctx = new ConceptValidationContext
        {
            ConceptName = "PortForward",
            Properties = new[]
            {
                new ConceptPropertyInfo("Name", "string", "test"),
                new ConceptPropertyInfo("ExternalPort", "int", 100000),  // out of range
                new ConceptPropertyInfo("InternalIp", "string", "192.168.10.10"),
                new ConceptPropertyInfo("InternalPort", "int", 443),
            }
        };

        var result = MetaConstraintRunner.RunConstraints(typeof(PortForwardAttribute), ctx);

        result.IsSatisfied.Should().BeFalse();
        result.Message.Should().Contain("ExternalPort 100000");
    }

    [Fact]
    public void unifi_generator_emits_json_for_a_simple_model()
    {
        var model = new HomeRouterModel
        {
            Vlans = new[] { new Vlan(10, "trusted", "192.168.10.0/24") },
            PortForwards = new[] { new PortForward("gitlab", 443, "192.168.10.10", 443, "tcp") }
        };

        var json = new UnifiHomeRouterGenerator().Generate(model);

        json.IsSuccess.Should().BeTrue();
        json.Value.Should().Contain("\"id\": 10");
        json.Value.Should().Contain("\"name\": \"trusted\"");
        json.Value.Should().Contain("\"src_port\": 443");
    }
}

What this gives you that bash doesn't

A bash homelab does not have a metamodel. Adding a "router config" feature to it is two new scripts: one to write the UniFi JSON, one to upload it. Validation is "the script ran without erroring".

A typed Ops.Dsl plugin gives you, for the same surface area:

  • Four typed concepts with [MetaConcept] companions
  • [MetaConstraint] validation at design time
  • A generator per vendor picked by the device's Vendor property
  • Cross-DSL link analysis that validates port forwards against VM IPs — automatically
  • Plugin distribution via NuGet
  • Tests for the plugin in isolation
  • Zero changes to HomeLab core — the metamodel registry handles discovery

The bargain pays back the first time you add a new sub-DSL and HomeLab's existing analyzers immediately validate your new concepts because they live in the same registry.


End of Act IX

We have shown how to ship two kinds of plugins: a contributor plugin (Cloudflare DNS provider) and a sub-DSL plugin (Ops.HomeRouter). Both use the same shape: a NuGet package, a manifest, [Injectable] types, and a thin entry point. Both integrate with HomeLab without any change to the core.

Act X is the closing: what is still missing (the items we deferred), and a conclusion that ties the whole series together.


⬇ Download