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 06: The K8s.Dsl Spec — MetaConcepts for Cluster Resources

"Before we write any kubectl wrapper, we declare the concepts. The metamodel comes first."


Why

K8s.Dsl needs a vocabulary. Not Kubernetes' YAML vocabulary — kind: Deployment is fine for Kubernetes, but we want a typed C# vocabulary that the rest of HomeLab can reason about. Specifically, we want concepts that the existing MetamodelRegistry (from homelab-docker Part 12) can register, validators can walk, code generators can emit, and analyzers can cross-check.

This is the same M3 framework Ops.Dsl uses. K8s.Dsl is a new sub-DSL (Ops.Cluster) plus a few k8s-specific extensions. The extension is shipped from a plugin, exactly the way homelab-docker Part 54 showed for Ops.HomeRouter. We are doing the same thing here, just for Kubernetes.

The thesis: K8s.Dsl declares ~12 [MetaConcept] types covering the cluster resources HomeLab needs to reason about. Each one has a companion class extending MetaConcept, properties decorated with [MetaProperty], and [MetaConstraint] validators. The plugin's Initialize method registers them in the shared registry; from that point, every analyzer in HomeLab sees them as first-class concepts.


1. Cluster

The top-level concept. A Cluster is a Kubernetes cluster with a name, a distribution (kubeadm or k3s), a topology (single, multi, ha), a version, and a list of nodes.

public sealed class ClusterConcept : MetaConcept
{
    public override string Name => "Cluster";
    public override Type AttributeType => typeof(ClusterAttribute);
}

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

    [MetaProperty("Distribution", "string", Required = true)]
    public string Distribution { get; }   // "kubeadm" | "k3s"

    [MetaProperty("Topology", "string", Required = true)]
    public string Topology { get; }       // "k8s-single" | "k8s-multi" | "k8s-ha"

    [MetaProperty("Version", "string", Required = true)]
    public string Version { get; }        // "v1.31.4"

    public ClusterAttribute(string name, string distribution, string topology, string version)
    {
        Name = name;
        Distribution = distribution;
        Topology = topology;
        Version = version;
    }
}

A user declares a cluster like this in their HomeLab project:

[Cluster(name: "acme",
         distribution: "kubeadm",
         topology: "k8s-multi",
         version: "v1.31.4")]
public sealed class AcmeCluster { }

The MetaConstraintRunner validates the values: distribution must be one of kubeadm, k3s, k0s; topology must be one of k8s-single, k8s-multi, k8s-ha; version must match vX.Y.Z regex. Invalid values fail at IDE-hover-error level, before any cluster is bootstrapped.

2. Node

A cluster has nodes. Each node has a role (control-plane or worker), a resource budget (cpus, memory, disk), and (in HA topology) an etcd participation flag.

[MetaConcept(typeof(NodeConcept))]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class NodeAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }
    [MetaProperty("Role", "string", Required = true)]
    public string Role { get; }       // "control-plane" | "worker"
    [MetaProperty("Cpus", "int", Required = true, Minimum = 1, Maximum = 64)]
    public int Cpus { get; set; } = 2;
    [MetaProperty("MemoryMb", "int", Required = true, Minimum = 1024, Maximum = 131072)]
    public int MemoryMb { get; set; } = 4096;
    [MetaProperty("DiskGb", "int", Minimum = 20, Maximum = 1024)]
    public int DiskGb { get; set; } = 80;
    public NodeAttribute(string name, string role) { Name = name; Role = role; }
}

Most of the time the user does not declare nodes by hand — the topology resolver from Part 08 does it. Manual declaration is for users who want explicit control over individual node sizing.

3. Namespace

A Kubernetes namespace, with optional resource quota, default network policy, and labels.

[MetaConcept(typeof(NamespaceConcept))]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class NamespaceAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }
    [MetaProperty("DefaultDeny", "bool")]
    public bool DefaultDeny { get; set; } = true;   // deny-all NetworkPolicy by default
    public NamespaceAttribute(string name) { Name = name; }
}

The DefaultDeny flag is opinionated: HomeLab K8s defaults to a deny-all NetworkPolicy per namespace, with explicit allow rules added by other contributors. This catches misconfigured pods early.

4. Workload

A workload is a runnable thing: a Deployment, StatefulSet, DaemonSet, Job, or CronJob. The concept is generic; the specific kind is a property.

[MetaConcept(typeof(WorkloadConcept))]
[AttributeUsage(AttributeTargets.Class)]
public sealed class WorkloadAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }
    [MetaProperty("Kind", "string", Required = true)]
    public string Kind { get; }       // "Deployment" | "StatefulSet" | "DaemonSet" | "Job" | "CronJob"
    [MetaProperty("Namespace", "string", Required = true)]
    public string Namespace { get; }
    [MetaProperty("Image", "string", Required = true)]
    public string Image { get; }
    [MetaProperty("Replicas", "int", Minimum = 0, Maximum = 100)]
    public int Replicas { get; set; } = 1;
    public WorkloadAttribute(string name, string kind, string @namespace, string image)
    {
        Name = name; Kind = kind; Namespace = @namespace; Image = image;
    }
}

5. Service

[MetaConcept(typeof(ServiceConcept))]
public sealed class ServiceAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("Type", "string")] public string Type { get; set; } = "ClusterIP";
    [MetaProperty("Port", "int", Required = true, Minimum = 1, Maximum = 65535)] public int Port { get; }
    [MetaProperty("TargetPort", "int", Minimum = 1, Maximum = 65535)] public int TargetPort { get; set; }
    public ServiceAttribute(string name, string @namespace, int port) { Name = name; Namespace = @namespace; Port = port; }
}

6. Ingress

[MetaConcept(typeof(IngressConcept))]
public sealed class IngressAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("Host", "string", Required = true)] public string Host { get; }
    [MetaProperty("Service", "string", Required = true)] public string ServiceName { get; }
    [MetaProperty("ServicePort", "int", Required = true)] public int ServicePort { get; }
    [MetaProperty("TlsSecretName", "string")] public string? TlsSecretName { get; set; }
    public IngressAttribute(string name, string @namespace, string host, string service, int servicePort)
    {
        Name = name; Namespace = @namespace; Host = host; ServiceName = service; ServicePort = servicePort;
    }
}

7. PersistentVolumeClaim

[MetaConcept(typeof(PvcConcept))]
public sealed class PvcAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("StorageClass", "string", Required = true)] public string StorageClass { get; }
    [MetaProperty("SizeGb", "int", Required = true, Minimum = 1, Maximum = 1024)] public int SizeGb { get; }
    [MetaProperty("AccessMode", "string")] public string AccessMode { get; set; } = "ReadWriteOnce";
    public PvcAttribute(string name, string @namespace, string storageClass, int sizeGb)
    {
        Name = name; Namespace = @namespace; StorageClass = storageClass; SizeGb = sizeGb;
    }
}

8. ConfigMap and Secret

[MetaConcept(typeof(ConfigMapConcept))]
public sealed class ConfigMapAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    public ConfigMapAttribute(string name, string @namespace) { Name = name; Namespace = @namespace; }
}

[MetaConcept(typeof(SecretConcept))]
public sealed class SecretAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("Source", "string", Required = true)] public string Source { get; }   // ISecretStore key reference
    public SecretAttribute(string name, string @namespace, string source) { Name = name; Namespace = @namespace; Source = source; }
}

Secret's Source property is a reference to an ISecretStore key (from homelab-docker Part 43). The actual value never appears in C# source. It is read at apply time from the secret store and pushed into the cluster as a Kubernetes Secret. We will see the bridge in Part 10.

9. Operator

A Kubernetes operator (CloudNativePG, MinIO Operator, etc.) is itself a concept. Installing one means installing its controller, registering its CRDs, and possibly creating instance resources.

[MetaConcept(typeof(OperatorConcept))]
public sealed class OperatorAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("HelmChart", "string", Required = true)] public string HelmChart { get; }   // e.g. "cnpg/cloudnative-pg"
    [MetaProperty("HelmVersion", "string", Required = true)] public string HelmVersion { get; }
    public OperatorAttribute(string name, string @namespace, string helmChart, string helmVersion)
    {
        Name = name; Namespace = @namespace; HelmChart = helmChart; HelmVersion = helmVersion;
    }
}

10. HelmRelease

A Helm release is a separate concept from a raw operator. Some installs are operators (with CRDs); some are just chart-shaped applications (kube-prometheus-stack, ingress-nginx, cert-manager). The HelmRelease concept covers both.

[MetaConcept(typeof(HelmReleaseConcept))]
public sealed class HelmReleaseAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("Chart", "string", Required = true)] public string Chart { get; }   // "ingress-nginx/ingress-nginx"
    [MetaProperty("Version", "string", Required = true)] public string Version { get; }
    [MetaProperty("ValuesFile", "string")] public string? ValuesFile { get; set; }
    public HelmReleaseAttribute(string name, string @namespace, string chart, string version)
    {
        Name = name; Namespace = @namespace; Chart = chart; Version = version;
    }
}

11. ArgoCdApplication

[MetaConcept(typeof(ArgoCdApplicationConcept))]
public sealed class ArgoCdApplicationAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("RepoUrl", "string", Required = true)] public string RepoUrl { get; }
    [MetaProperty("Path", "string", Required = true)] public string Path { get; }
    [MetaProperty("TargetRevision", "string")] public string TargetRevision { get; set; } = "HEAD";
    [MetaProperty("DestinationServer", "string")] public string DestinationServer { get; set; } = "https://kubernetes.default.svc";
    [MetaProperty("DestinationNamespace", "string")] public string DestinationNamespace { get; set; }
    public ArgoCdApplicationAttribute(string name, string @namespace, string repoUrl, string path)
    {
        Name = name; Namespace = @namespace; RepoUrl = repoUrl; Path = path; DestinationNamespace = @namespace;
    }
}

This concept is the bridge to the GitOps repo generator from Part 33. Each [ArgoCdApplication] declaration produces an ArgoCD Application CRD manifest written to the GitOps repository.

12. NetworkPolicy

[MetaConcept(typeof(NetworkPolicyConcept))]
public sealed class NetworkPolicyAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)] public string Name { get; }
    [MetaProperty("Namespace", "string", Required = true)] public string Namespace { get; }
    [MetaProperty("PodSelector", "string")] public string? PodSelector { get; set; }
    [MetaProperty("Ingress", "string")] public string? IngressRules { get; set; }   // JSON-encoded rule set
    [MetaProperty("Egress", "string")] public string? EgressRules { get; set; }
    public NetworkPolicyAttribute(string name, string @namespace) { Name = name; Namespace = @namespace; }
}

NetworkPolicy is special: it requires CNI support (Calico/Cilium/Flannel-with-policy), and the MetaConstraintRunner cross-checks that the active cluster's CNI supports policies before allowing the manifest to be generated.


Cross-DSL constraints

The point of putting these concepts in the metamodel registry is cross-DSL validation. K8s.Dsl declares constraints that walk both the K8s.Dsl concepts and the existing Ops.Dsl concepts to catch errors that span DSLs:

[MetaConcept(typeof(IngressConcept))]
[MetaConstraint("ServiceMustExist", nameof(ServiceMustExistInSameNamespace),
    Message = "Ingress points at a Service that does not exist in the same namespace")]
public sealed class IngressAttribute : Attribute
{
    // ... properties ...

    public static ConstraintResult ServiceMustExistInSameNamespace(ConceptValidationContext ctx)
    {
        var ingressNs = (string)ctx.GetProperty("Namespace")!;
        var serviceName = (string)ctx.GetProperty("Service")!;

        var allServices = ctx.MetamodelRegistry
            .GetInstancesOf<ServiceConcept>()
            .Where(s => (string)s.GetProperty("Namespace")! == ingressNs)
            .Select(s => (string)s.GetProperty("Name")!);

        return allServices.Contains(serviceName)
            ? ConstraintResult.Satisfied()
            : ConstraintResult.Failed($"No Service named '{serviceName}' in namespace '{ingressNs}'");
    }
}

This constraint runs at design time (via the MetaConstraintRunner) and at validation stage in the pipeline. The compiler-or-pipeline tells the developer "your Ingress points at nothing" before they ever run kubectl apply.


What this gives you that raw YAML doesn't

A Deployment YAML is just a string. There is no compiler that knows what its selector is supposed to match, no analyzer that knows whether the Image exists in the registry, no validator that knows whether the PodSpec.containers[0].name matches the metadata.name. The Kubernetes API server catches some of these at apply time; the rest you discover when the pod fails to schedule or the service has no endpoints.

A typed [MetaConcept] vocabulary gives you, for the same surface area:

  • A registry of every cluster concept that analyzers can walk
  • Cross-DSL validation (Ingress → Service, Service → Workload, Workload → Image, etc.) at design time
  • Generator hooks: every analyzer that walks the metamodel can emit something — the manifests themselves, the documentation, the dependency graph diagram, the test stubs
  • Plugin extensibility: a future plugin can add more concepts (e.g. a KafkaTopic concept from a Kafka operator plugin) to the same registry

The bargain is the cheapest of all the architectural decisions in K8s.Dsl. ~12 attribute classes plus their companions plus the constraints, total maybe 600 lines of declarative C#. Everything downstream — the manifest generator, the validator, the documentation builder, the cross-DSL link checker — operates on the metamodel and gets the value for free.


⬇ Download