Part 07: IK8sManifestContributor and IHelmReleaseContributor
"Some things are manifests. Some things are Helm releases. Treat them as two separate roles instead of cramming them into one."
Why
Part 06 declared the concepts. This part declares the contributors that produce concrete artifacts from those concepts. There are two of them, deliberately separated:
IK8sManifestContributor— for raw Kubernetes manifests. A Deployment YAML, a Service YAML, a NetworkPolicy YAML, a CRD instance. The contributor produces typed C# objects that serialize to manifests.IHelmReleaseContributor— for Helm releases. AHelmReleaseis a chart name + version + values + namespace tuple, not a set of manifests. The chart itself is downloaded by Helm from a registry; HomeLab does not generate the manifests inside it.
These are two different things, and putting them in the same contract would force every contributor implementation to know about both kinds of artifact even when it only deals with one. ISP from homelab-docker Part 08 says: split.
The thesis: two parallel role contracts, two contributor patterns, one shared Kubernetes.Bundle library that holds both kinds of output. Architecture tests prevent two contributors of either kind from defining the same resource.
The shape
namespace FrenchExDev.HomeLab.Plugin.K8sDsl;
public interface IK8sManifestContributor
{
string TargetCluster { get; }
bool ShouldContribute() => true;
void Contribute(KubernetesBundle bundle);
}
public interface IHelmReleaseContributor
{
string TargetCluster { get; }
bool ShouldContribute() => true;
void Contribute(KubernetesBundle bundle);
}namespace FrenchExDev.HomeLab.Plugin.K8sDsl;
public interface IK8sManifestContributor
{
string TargetCluster { get; }
bool ShouldContribute() => true;
void Contribute(KubernetesBundle bundle);
}
public interface IHelmReleaseContributor
{
string TargetCluster { get; }
bool ShouldContribute() => true;
void Contribute(KubernetesBundle bundle);
}Both have the same shape (one method, one property, one optional ShouldContribute). The difference is what they put into the bundle.
public sealed class KubernetesBundle
{
public Dictionary<string, NamespaceManifest> Namespaces { get; set; } = new();
public List<DeploymentManifest> Deployments { get; set; } = new();
public List<StatefulSetManifest> StatefulSets { get; set; } = new();
public List<DaemonSetManifest> DaemonSets { get; set; } = new();
public List<JobManifest> Jobs { get; set; } = new();
public List<CronJobManifest> CronJobs { get; set; } = new();
public List<ServiceManifest> Services { get; set; } = new();
public List<IngressManifest> Ingresses { get; set; } = new();
public List<PvcManifest> PersistentVolumeClaims { get; set; } = new();
public List<ConfigMapManifest> ConfigMaps { get; set; } = new();
public List<SecretManifest> Secrets { get; set; } = new();
public List<NetworkPolicyManifest> NetworkPolicies { get; set; } = new();
public List<RawManifest> CrdInstances { get; set; } = new(); // anything K8s.Dsl doesn't have a typed shape for
public List<HelmReleaseSpec> HelmReleases { get; set; } = new();
}public sealed class KubernetesBundle
{
public Dictionary<string, NamespaceManifest> Namespaces { get; set; } = new();
public List<DeploymentManifest> Deployments { get; set; } = new();
public List<StatefulSetManifest> StatefulSets { get; set; } = new();
public List<DaemonSetManifest> DaemonSets { get; set; } = new();
public List<JobManifest> Jobs { get; set; } = new();
public List<CronJobManifest> CronJobs { get; set; } = new();
public List<ServiceManifest> Services { get; set; } = new();
public List<IngressManifest> Ingresses { get; set; } = new();
public List<PvcManifest> PersistentVolumeClaims { get; set; } = new();
public List<ConfigMapManifest> ConfigMaps { get; set; } = new();
public List<SecretManifest> Secrets { get; set; } = new();
public List<NetworkPolicyManifest> NetworkPolicies { get; set; } = new();
public List<RawManifest> CrdInstances { get; set; } = new(); // anything K8s.Dsl doesn't have a typed shape for
public List<HelmReleaseSpec> HelmReleases { get; set; } = new();
}The bundle has typed lists for the standard kinds and a RawManifest escape hatch for anything K8s.Dsl does not have a first-class type for (CRD instances of operator types like cnpg.io/Cluster, MinIO Tenant, ArgoCD Application, etc.).
A typed manifest is a [Builder] record:
[Builder]
public sealed record DeploymentManifest
{
public required string Name { get; init; }
public required string Namespace { get; init; }
public Dictionary<string, string> Labels { get; init; } = new();
public Dictionary<string, string> Annotations { get; init; } = new();
public required PodSpec PodSpec { get; init; }
public int Replicas { get; init; } = 1;
public DeploymentStrategy Strategy { get; init; } = DeploymentStrategy.RollingUpdate();
public IReadOnlyDictionary<string, string>? Selector { get; init; }
}
[Builder]
public sealed record PodSpec
{
public IReadOnlyList<Container> Containers { get; init; } = Array.Empty<Container>();
public IReadOnlyList<Volume> Volumes { get; init; } = Array.Empty<Volume>();
public string? ServiceAccountName { get; init; }
public IReadOnlyDictionary<string, string>? NodeSelector { get; init; }
public IReadOnlyList<Toleration>? Tolerations { get; init; }
public Affinity? Affinity { get; init; }
public PodSecurityContext? SecurityContext { get; init; }
public string? RestartPolicy { get; init; }
}[Builder]
public sealed record DeploymentManifest
{
public required string Name { get; init; }
public required string Namespace { get; init; }
public Dictionary<string, string> Labels { get; init; } = new();
public Dictionary<string, string> Annotations { get; init; } = new();
public required PodSpec PodSpec { get; init; }
public int Replicas { get; init; } = 1;
public DeploymentStrategy Strategy { get; init; } = DeploymentStrategy.RollingUpdate();
public IReadOnlyDictionary<string, string>? Selector { get; init; }
}
[Builder]
public sealed record PodSpec
{
public IReadOnlyList<Container> Containers { get; init; } = Array.Empty<Container>();
public IReadOnlyList<Volume> Volumes { get; init; } = Array.Empty<Volume>();
public string? ServiceAccountName { get; init; }
public IReadOnlyDictionary<string, string>? NodeSelector { get; init; }
public IReadOnlyList<Toleration>? Tolerations { get; init; }
public Affinity? Affinity { get; init; }
public PodSecurityContext? SecurityContext { get; init; }
public string? RestartPolicy { get; init; }
}These come from Kubernetes.Bundle, the new typed-manifest library that ships with K8s.Dsl. It is the parallel of DockerCompose.Bundle from homelab-docker. Same [Builder] source generator. Same async-validated construction. Same serializer (a KubernetesYamlSerializer that knows how to emit valid Kubernetes YAML — including the multi-document --- separators).
A HelmReleaseSpec:
[Builder]
public sealed record HelmReleaseSpec
{
public required string Name { get; init; }
public required string Namespace { get; init; }
public required string Chart { get; init; } // "ingress-nginx/ingress-nginx"
public required string Version { get; init; }
public required string RepoUrl { get; init; } // "https://kubernetes.github.io/ingress-nginx"
public Dictionary<string, object?> Values { get; init; } = new();
public bool CreateNamespace { get; init; } = true;
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(10);
public bool Wait { get; init; } = true;
}[Builder]
public sealed record HelmReleaseSpec
{
public required string Name { get; init; }
public required string Namespace { get; init; }
public required string Chart { get; init; } // "ingress-nginx/ingress-nginx"
public required string Version { get; init; }
public required string RepoUrl { get; init; } // "https://kubernetes.github.io/ingress-nginx"
public Dictionary<string, object?> Values { get; init; } = new();
public bool CreateNamespace { get; init; } = true;
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(10);
public bool Wait { get; init; } = true;
}The Values dictionary is the equivalent of a Helm values.yaml. K8s.Dsl ships with strongly-typed Values records for the most common charts (CloudNativePG, MinIO operator, kube-prometheus-stack, ArgoCD, cert-manager, external-dns, ingress-nginx, GitLab) so contributors do not have to use a stringly-typed dictionary.
A worked example: an IK8sManifestContributor
[Injectable(ServiceLifetime.Singleton)]
public sealed class AcmeApiManifestContributor : IK8sManifestContributor
{
private readonly HomeLabConfig _config;
public AcmeApiManifestContributor(IOptions<HomeLabConfig> config) => _config = config.Value;
public string TargetCluster => "acme";
public void Contribute(KubernetesBundle bundle)
{
const string ns = "acme-prod";
bundle.Namespaces[ns] ??= new NamespaceManifest { Name = ns };
bundle.Deployments.Add(new DeploymentManifest
{
Name = "acme-api",
Namespace = ns,
Replicas = 3,
Labels = new() { ["app"] = "acme-api" },
Selector = new Dictionary<string, string> { ["app"] = "acme-api" },
PodSpec = new PodSpec
{
Containers = new[]
{
new Container
{
Name = "api",
Image = $"registry.{_config.Acme.Tld}/acme/api:1.4.7",
Ports = new[] { new ContainerPort { Name = "http", ContainerPortNumber = 8080 } },
Resources = new ResourceRequirements
{
Requests = new() { ["cpu"] = "250m", ["memory"] = "256Mi" },
Limits = new() { ["cpu"] = "1", ["memory"] = "512Mi" }
},
LivenessProbe = new Probe { HttpGet = new() { Path = "/healthz", Port = 8080 }, InitialDelaySeconds = 10 },
ReadinessProbe = new Probe { HttpGet = new() { Path = "/ready", Port = 8080 }, InitialDelaySeconds = 5 },
EnvFrom = new[]
{
new EnvFromSource { ConfigMapRef = new() { Name = "acme-api-config" } },
new EnvFromSource { SecretRef = new() { Name = "acme-api-secrets" } }
}
}
}
}
});
bundle.Services.Add(new ServiceManifest
{
Name = "acme-api",
Namespace = ns,
Selector = new Dictionary<string, string> { ["app"] = "acme-api" },
Ports = new[] { new ServicePort { Name = "http", Port = 80, TargetPort = 8080 } }
});
bundle.Ingresses.Add(new IngressManifest
{
Name = "acme-api",
Namespace = ns,
IngressClassName = "nginx",
Rules = new[]
{
new IngressRule
{
Host = $"api.{_config.Acme.Tld}",
Paths = new[] { new IngressPath { Path = "/", PathType = "Prefix", ServiceName = "acme-api", ServicePort = 80 } }
}
},
Tls = new[] { new IngressTls { Hosts = new[] { $"api.{_config.Acme.Tld}" }, SecretName = "acme-api-tls" } }
});
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class AcmeApiManifestContributor : IK8sManifestContributor
{
private readonly HomeLabConfig _config;
public AcmeApiManifestContributor(IOptions<HomeLabConfig> config) => _config = config.Value;
public string TargetCluster => "acme";
public void Contribute(KubernetesBundle bundle)
{
const string ns = "acme-prod";
bundle.Namespaces[ns] ??= new NamespaceManifest { Name = ns };
bundle.Deployments.Add(new DeploymentManifest
{
Name = "acme-api",
Namespace = ns,
Replicas = 3,
Labels = new() { ["app"] = "acme-api" },
Selector = new Dictionary<string, string> { ["app"] = "acme-api" },
PodSpec = new PodSpec
{
Containers = new[]
{
new Container
{
Name = "api",
Image = $"registry.{_config.Acme.Tld}/acme/api:1.4.7",
Ports = new[] { new ContainerPort { Name = "http", ContainerPortNumber = 8080 } },
Resources = new ResourceRequirements
{
Requests = new() { ["cpu"] = "250m", ["memory"] = "256Mi" },
Limits = new() { ["cpu"] = "1", ["memory"] = "512Mi" }
},
LivenessProbe = new Probe { HttpGet = new() { Path = "/healthz", Port = 8080 }, InitialDelaySeconds = 10 },
ReadinessProbe = new Probe { HttpGet = new() { Path = "/ready", Port = 8080 }, InitialDelaySeconds = 5 },
EnvFrom = new[]
{
new EnvFromSource { ConfigMapRef = new() { Name = "acme-api-config" } },
new EnvFromSource { SecretRef = new() { Name = "acme-api-secrets" } }
}
}
}
}
});
bundle.Services.Add(new ServiceManifest
{
Name = "acme-api",
Namespace = ns,
Selector = new Dictionary<string, string> { ["app"] = "acme-api" },
Ports = new[] { new ServicePort { Name = "http", Port = 80, TargetPort = 8080 } }
});
bundle.Ingresses.Add(new IngressManifest
{
Name = "acme-api",
Namespace = ns,
IngressClassName = "nginx",
Rules = new[]
{
new IngressRule
{
Host = $"api.{_config.Acme.Tld}",
Paths = new[] { new IngressPath { Path = "/", PathType = "Prefix", ServiceName = "acme-api", ServicePort = 80 } }
}
},
Tls = new[] { new IngressTls { Hosts = new[] { $"api.{_config.Acme.Tld}" }, SecretName = "acme-api-tls" } }
});
}
}The contributor produces a Deployment, a Service, and an Ingress. It declares a default-deny namespace in passing (idempotently — ??= ensures only one contributor wins on the namespace declaration). It uses typed records throughout. There is no string YAML anywhere.
The architecture test from homelab-docker Part 32 applies unchanged: no two IK8sManifestContributor implementations can define the same (Kind, Name, Namespace) tuple. The detection happens at unit-test speed by running each contributor in isolation against an empty bundle and tallying the resources it added.
A worked example: an IHelmReleaseContributor
[Injectable(ServiceLifetime.Singleton)]
public sealed class CloudNativePgHelmReleaseContributor : IHelmReleaseContributor
{
public string TargetCluster => "*"; // any cluster — CloudNativePG is universal
public void Contribute(KubernetesBundle bundle)
{
bundle.HelmReleases.Add(new HelmReleaseSpec
{
Name = "cloudnative-pg",
Namespace = "cnpg-system",
Chart = "cnpg/cloudnative-pg",
Version = "0.23.0",
RepoUrl = "https://cloudnative-pg.github.io/charts",
CreateNamespace = true,
Wait = true,
Timeout = TimeSpan.FromMinutes(5),
Values = new()
{
["replicaCount"] = 1,
["resources"] = new Dictionary<string, object?>
{
["requests"] = new Dictionary<string, object?> { ["cpu"] = "100m", ["memory"] = "200Mi" },
["limits"] = new Dictionary<string, object?> { ["cpu"] = "500m", ["memory"] = "500Mi" }
},
["monitoring"] = new Dictionary<string, object?> { ["podMonitorEnabled"] = true }
}
});
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class CloudNativePgHelmReleaseContributor : IHelmReleaseContributor
{
public string TargetCluster => "*"; // any cluster — CloudNativePG is universal
public void Contribute(KubernetesBundle bundle)
{
bundle.HelmReleases.Add(new HelmReleaseSpec
{
Name = "cloudnative-pg",
Namespace = "cnpg-system",
Chart = "cnpg/cloudnative-pg",
Version = "0.23.0",
RepoUrl = "https://cloudnative-pg.github.io/charts",
CreateNamespace = true,
Wait = true,
Timeout = TimeSpan.FromMinutes(5),
Values = new()
{
["replicaCount"] = 1,
["resources"] = new Dictionary<string, object?>
{
["requests"] = new Dictionary<string, object?> { ["cpu"] = "100m", ["memory"] = "200Mi" },
["limits"] = new Dictionary<string, object?> { ["cpu"] = "500m", ["memory"] = "500Mi" }
},
["monitoring"] = new Dictionary<string, object?> { ["podMonitorEnabled"] = true }
}
});
}
}The contributor adds one HelmRelease. The actual chart is downloaded by Helm at apply time from https://cloudnative-pg.github.io/charts. K8s.Dsl does not store the chart bytes; it stores the spec of which chart to install with which values.
A second contributor can then add a CloudNativePG Cluster CRD instance via IK8sManifestContributor and the RawManifest escape hatch:
[Injectable(ServiceLifetime.Singleton)]
public sealed class AcmePostgresClusterContributor : IK8sManifestContributor
{
public string TargetCluster => "acme";
public void Contribute(KubernetesBundle bundle)
{
bundle.CrdInstances.Add(new RawManifest
{
ApiVersion = "postgresql.cnpg.io/v1",
Kind = "Cluster",
Metadata = new() { Name = "acme-pg", Namespace = "acme-prod" },
Spec = new Dictionary<string, object?>
{
["instances"] = 3,
["postgresql"] = new Dictionary<string, object?>
{
["parameters"] = new Dictionary<string, object?>
{
["max_connections"] = "200"
}
},
["bootstrap"] = new Dictionary<string, object?>
{
["initdb"] = new Dictionary<string, object?>
{
["database"] = "acme",
["owner"] = "acme"
}
},
["storage"] = new Dictionary<string, object?>
{
["size"] = "10Gi",
["storageClass"] = "longhorn"
}
}
});
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class AcmePostgresClusterContributor : IK8sManifestContributor
{
public string TargetCluster => "acme";
public void Contribute(KubernetesBundle bundle)
{
bundle.CrdInstances.Add(new RawManifest
{
ApiVersion = "postgresql.cnpg.io/v1",
Kind = "Cluster",
Metadata = new() { Name = "acme-pg", Namespace = "acme-prod" },
Spec = new Dictionary<string, object?>
{
["instances"] = 3,
["postgresql"] = new Dictionary<string, object?>
{
["parameters"] = new Dictionary<string, object?>
{
["max_connections"] = "200"
}
},
["bootstrap"] = new Dictionary<string, object?>
{
["initdb"] = new Dictionary<string, object?>
{
["database"] = "acme",
["owner"] = "acme"
}
},
["storage"] = new Dictionary<string, object?>
{
["size"] = "10Gi",
["storageClass"] = "longhorn"
}
}
});
}
}CRD instances are typed by the user's project, not by K8s.Dsl, because CRDs are domain-specific and K8s.Dsl cannot ship typed shapes for every operator under the sun. The RawManifest shape gives the user a structured C# representation of the CRD that is almost as good as a fully typed one — the property names are wrong (the CRD calls instances, K8s.Dsl calls Spec["instances"]), but the validation, the events, the architecture rules all still apply.
The wiring
Both contributor lists are injected into the new K8s Generate stage, which mirrors the existing GenerateStage from homelab-docker Part 07:
[Injectable(ServiceLifetime.Singleton)]
[Order(35)] // after the standard generate stage
public sealed class K8sGenerateStage : IHomeLabStage
{
public string Name => "k8s-generate";
public int Order => 35;
private readonly IEnumerable<IK8sManifestContributor> _manifests;
private readonly IEnumerable<IHelmReleaseContributor> _helm;
private readonly IK8sBundleWriter _writer;
public K8sGenerateStage(
IEnumerable<IK8sManifestContributor> manifests,
IEnumerable<IHelmReleaseContributor> helm,
IK8sBundleWriter writer)
{
_manifests = manifests;
_helm = helm;
_writer = writer;
}
public async Task<Result<HomeLabContext>> RunAsync(HomeLabContext ctx, CancellationToken ct)
{
var clusterName = ctx.Config!.K8s?.ClusterName ?? throw new InvalidOperationException("no cluster configured");
var bundle = new KubernetesBundle();
foreach (var c in _manifests.Where(m => m.TargetCluster == clusterName || m.TargetCluster == "*").Where(c => c.ShouldContribute()))
c.Contribute(bundle);
foreach (var c in _helm.Where(h => h.TargetCluster == clusterName || h.TargetCluster == "*").Where(c => c.ShouldContribute()))
c.Contribute(bundle);
var writeResult = await _writer.WriteAsync(bundle, ctx.Request.OutputDir, ct);
if (writeResult.IsFailure) return writeResult.Map<HomeLabContext>();
return Result.Success(ctx with
{
Artifacts = ctx.Artifacts! with { K8sBundle = writeResult.Value }
});
}
}[Injectable(ServiceLifetime.Singleton)]
[Order(35)] // after the standard generate stage
public sealed class K8sGenerateStage : IHomeLabStage
{
public string Name => "k8s-generate";
public int Order => 35;
private readonly IEnumerable<IK8sManifestContributor> _manifests;
private readonly IEnumerable<IHelmReleaseContributor> _helm;
private readonly IK8sBundleWriter _writer;
public K8sGenerateStage(
IEnumerable<IK8sManifestContributor> manifests,
IEnumerable<IHelmReleaseContributor> helm,
IK8sBundleWriter writer)
{
_manifests = manifests;
_helm = helm;
_writer = writer;
}
public async Task<Result<HomeLabContext>> RunAsync(HomeLabContext ctx, CancellationToken ct)
{
var clusterName = ctx.Config!.K8s?.ClusterName ?? throw new InvalidOperationException("no cluster configured");
var bundle = new KubernetesBundle();
foreach (var c in _manifests.Where(m => m.TargetCluster == clusterName || m.TargetCluster == "*").Where(c => c.ShouldContribute()))
c.Contribute(bundle);
foreach (var c in _helm.Where(h => h.TargetCluster == clusterName || h.TargetCluster == "*").Where(c => c.ShouldContribute()))
c.Contribute(bundle);
var writeResult = await _writer.WriteAsync(bundle, ctx.Request.OutputDir, ct);
if (writeResult.IsFailure) return writeResult.Map<HomeLabContext>();
return Result.Success(ctx with
{
Artifacts = ctx.Artifacts! with { K8sBundle = writeResult.Value }
});
}
}The K8s generate stage runs after the standard generate stage ([Order(30)]) and uses the same IBundleWriter pattern. The K8sBundleWriter knows how to serialize each kind into one or more YAML files under out/k8s/.
The test
public sealed class K8sContributorPatternTests
{
[Fact]
public void no_two_manifest_contributors_define_the_same_deployment()
{
var contributors = StandardK8sContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new KubernetesBundle();
c.Contribute(probe);
foreach (var d in probe.Deployments)
{
var key = $"{d.Namespace}/{d.Name}";
counts[key] = counts.GetValueOrDefault(key) + 1;
}
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty(
"two contributors are defining the same Deployment — only one should");
}
[Fact]
public void no_two_helm_release_contributors_target_the_same_release_name()
{
var contributors = StandardHelmReleaseContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new KubernetesBundle();
c.Contribute(probe);
foreach (var hr in probe.HelmReleases)
{
var key = $"{hr.Namespace}/{hr.Name}";
counts[key] = counts.GetValueOrDefault(key) + 1;
}
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty();
}
[Fact]
public void target_cluster_filter_excludes_other_clusters()
{
var c = new AcmeApiManifestContributor(/* ... */);
c.TargetCluster.Should().Be("acme");
var generator = TestHandlers.K8sGenerateStage(
manifests: new[] { c },
helm: Array.Empty<IHelmReleaseContributor>(),
writer: Mock.Of<IK8sBundleWriter>());
var ctxAcme = new HomeLabContext(/* config: cluster acme */);
var ctxGlobex = new HomeLabContext(/* config: cluster globex */);
// For acme, the contributor runs
// For globex, it does not — verified by counting Deployments in the bundle
}
}public sealed class K8sContributorPatternTests
{
[Fact]
public void no_two_manifest_contributors_define_the_same_deployment()
{
var contributors = StandardK8sContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new KubernetesBundle();
c.Contribute(probe);
foreach (var d in probe.Deployments)
{
var key = $"{d.Namespace}/{d.Name}";
counts[key] = counts.GetValueOrDefault(key) + 1;
}
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty(
"two contributors are defining the same Deployment — only one should");
}
[Fact]
public void no_two_helm_release_contributors_target_the_same_release_name()
{
var contributors = StandardHelmReleaseContributors();
var counts = new Dictionary<string, int>();
foreach (var c in contributors)
{
var probe = new KubernetesBundle();
c.Contribute(probe);
foreach (var hr in probe.HelmReleases)
{
var key = $"{hr.Namespace}/{hr.Name}";
counts[key] = counts.GetValueOrDefault(key) + 1;
}
}
counts.Where(kv => kv.Value > 1).Should().BeEmpty();
}
[Fact]
public void target_cluster_filter_excludes_other_clusters()
{
var c = new AcmeApiManifestContributor(/* ... */);
c.TargetCluster.Should().Be("acme");
var generator = TestHandlers.K8sGenerateStage(
manifests: new[] { c },
helm: Array.Empty<IHelmReleaseContributor>(),
writer: Mock.Of<IK8sBundleWriter>());
var ctxAcme = new HomeLabContext(/* config: cluster acme */);
var ctxGlobex = new HomeLabContext(/* config: cluster globex */);
// For acme, the contributor runs
// For globex, it does not — verified by counting Deployments in the bundle
}
}What this gives you that raw kubectl apply -f doesn't
A directory of hand-written YAML files is the standard Kubernetes development practice. It works. It also drifts: services rename, namespaces change, secrets get rotated, ports get renumbered, and the YAML files are updated one at a time by humans who may or may not remember every reference.
Two typed contributor patterns with a shared bundle give you, for the same surface area:
- Typed manifests with
[Builder]source-generated builders, validated at construction - Typed Helm releases with strongly-typed
Valuesrecords for the common charts - Architecture tests that prevent two contributors from defining the same resource
- Per-cluster filtering via
TargetClusterso the same contributor binary can be used in multiple clusters - Plugin extensibility — a future plugin can add new contributors that the existing generators automatically pick up
- One generator that produces both manifests and Helm release specs from the same C# source
The bargain pays back the first time you rename a Service in C# and watch the Ingress that points at it update at the next homelab k8s generate without anyone touching the YAML.