⚠️ Work in Progress
You are viewing development documentation built from the latest commit on main. APIs and features are subject to change.

Kubernetes Builders

Kubernetes Builders - The Builder Contract

Go Reference Go Reference

The kubernetes package provides the shared scheme, the generic constructor and the admissible sugar helpers for building Kubernetes objects. This page is the normative text of the builder contract (ADR-038, “thin core + admissible sugar”): every package under pkg/kubernetes/... follows it, and the tests described below enforce it.

Import

import "github.com/go-kure/kure/pkg/kubernetes"

1. Canonical path

For every registered kind the upstream Go struct is the construction API:

d := kubernetes.CreateDeployment("web", "default")
d.Spec.Replicas = ptr.To[int32](3)
d.Spec.Template.Spec.ServiceAccountName = "web"

kure does not provide, and its docs do not suggest, a kure function for plain field access. A whole-spec setter is a bare assignment and is not part of the contract.

2. Constructors

Create[T any, PT interface{ *T; client.Object }](name, namespace string) PT allocates T, sets TypeMeta from the registered scheme (the same lookup GetGroupVersionKind uses), sets metadata.name and metadata.namespace, and nothing else. The pointer type is inferred from the one type argument:

d  := kubernetes.Create[appsv1.Deployment]("web", "default")
ns := kubernetes.Create[corev1.Namespace]("platform", "")   // cluster-scoped: pass ""

An unregistered type panics. That is a programming error, the same rule as a nil receiver, not a runtime condition to handle.

Per-kind wrappers keep call sites readable and carry the scope in their signature: CreateDeployment(name, namespace) for namespaced kinds, CreateNamespace(name) for cluster-scoped ones. They live in zz_generated_create.go in each package, are generated from the scheme and the scope table in pkg/kubernetes/internal/kinds, and are never hand-written. A kind registered in the scheme without a wrapper fails the identity test; a wrapper that sets anything beyond identity fails it too.

Sub-types that are not client.Object (Container, PodSpec, ResourceRequirements, an IngressRule, a PVC used as a template) get no generated constructor. A struct literal is the idiom: build the value directly, as &corev1.Container{Name: "app", Image: "nginx"}.

In this package two hand-written sub-type constructors survive, both because they do more than wrap a literal and both under review for the next work item: CreateResourceRequirements() returns empty Requests and Limits maps, and CreateIngressPath(path, pathType, service, port) assembles a nested HTTPIngressPath. Everything else in that shape has been removed from this package.

The kind sub-packages are a different matter and are not in scope here. pkg/kubernetes/fluxcd still exports twenty-four hand-written sub-type constructors (CreateSourceReference, CreatePostBuild, CreateDecryption, CreateCommonMetadata, CreateDriftDetection, CreatePostRendererKustomize, CreateGitSpec and the rest), and pkg/kubernetes/prometheus exports CreateRuleGroup. They remain available and unchanged; whether they belong under the contract is the sealing work item’s question, not this one’s.

Regenerating the wrappers

make gen-builders      # or: mise run builders:generate
make check-builders    # or: mise run builders:check -- exit 1 when stale

check-builders runs in the CI validate job, so a dependency bump that adds or drops a kind fails until the wrappers are regenerated and committed. Renovate runs scripts/gen-builders.sh generate itself after any Go module bump.

3. Sugar admission

An exported Set* / Add* function under pkg/kubernetes/... is admissible when its body does one of:

  • (a) appends to a slice field, or inserts into a map field. Going through a local counts only when that local came from the field it is written back to: a collection the helper builds itself and then assigns replaces the field’s contents, which is not adding to it;
  • (b) assigns to a pointer-typed field (x.F = &v; initialising a nil pointer intermediate before assigning through it is the same thing);
  • (c) constructs an upstream struct literal setting two or more fields, or a nested literal. A slice or map literal is not class (c): it replaces a collection rather than composing a value.

Every admitted operation carries a value the caller supplied. An append of a constant, a map insert of a constant, a struct literal built entirely from constants, or a pointer allocated (new(T)) and never written through, all set a value the caller never named and are inadmissible (§4). The zero-value init a helper guards a nil field with (if o.Labels == nil { o.Labels = map[string]string{} }) is not such a value.

A body that is a single assignment to a non-pointer field is inadmissible regardless of path depth: writing Spec.Template.Spec.ServiceAccountName is still one assignment, and two such assignments in one body are two forwarders, not a composite. A bare assignment next to an admitted operation is inadmissible when its value is not an argument: an append that also sets a scalar to a literal or a computed value touches a field the caller did not name (§4). Forwarding a second argument alongside (SetHPAMinMaxReplicas(hpa, 2, 10)) leaves the class alone. A helper that returns anything, an error included, is inadmissible whatever its body does (§4 allows no error return: a nil receiver panics). A nil receiver guard admits nothing on its own. A body that assigns nil to any field, directly or as a keyed value inside a literal (the literal nil, a typed conversion of it, or a local known to be nil), is inadmissible whatever else it does, because it clears a field the caller did not name (§4); a helper that must replace one member of a one-of takes the whole one-of as its argument instead.

A helper reaches the object it writes through a parameter that can carry the write back to the caller: a pointer, map, slice or interface. A struct taken by value is a copy, so a helper written that way changes nothing the caller can see and is inadmissible.

TestAdmission_SugarHelpersAreClassAdmissible classifies every helper with go/ast and type information (pkg/kubernetes/internal/admission) and fails naming any helper outside (a)-(c). It is syntactic and deliberately conservative: it does not follow control flow, so a write guarded by an optional-value condition (if name != "" { ... }, forbidden by §4) is not detected by the test. That idiom is caught by review and by the helper’s own golden test. A function literal’s body is not the helper’s own body: an append inside a closure the helper never calls is a no-op no caller sees, so it admits nothing. pkg/kubernetes/testdata/admission_exclusions.txt listed the helpers tolerated while the prune work item of the epic ran; that file is now empty and stays empty. Entries only ever leave, and a stale entry fails the test.

4. Purity

  • Sugar takes exactly the value it writes. Value arguments are fine (SetDeploymentReplicas(d, 3)) because nil stays expressible on the canonical path.
  • No defaulting. No validation: Validate* helpers stay explicit, opt-in calls. No touching a field the caller did not name. No error return, because nothing in an assignment can fail; a nil receiver panics.
  • The if x != "" { set(x) } idiom is forbidden in sugar. A composite that treats an argument as optional documents that per argument in its doc comment.
  • Opinions are nouns. kure may hold knowledge a caller names (RestrictedSecurityContext(), AddHPACPUMetric(hpa, 80)) and never applies it to something the caller did not ask about. Every composite carries a golden test of its complete output so an injected value is visible in the diff that adds it.

5. Metadata

One helper set over metav1.Object covers every kind, including kinds kure never names:

kubernetes.SetLabels(obj, map[string]string{"app": "web"})
kubernetes.AddLabel(obj, "tier", "frontend")           // initialises a nil map
kubernetes.SetAnnotations(obj, map[string]string{"owner": "platform"})
kubernetes.AddAnnotation(obj, "note", "rotated 2026-09")

These four are admitted by name; per-kind label and annotation helpers are not part of the contract, and none remain — AddNamespaceLabel, AddClusterAnnotation, SetConfigMapLabels and the twenty-nine others like them were removed, since the four above already reach every kind through metav1.Object. Two helpers keep a metadata-shaped name while writing something else: cilium’s Set*PolicyLabels write the policy’s spec.labels, and prometheus’s Add*TargetLabel appends to a scrape spec’s target-label list. Neither is ObjectMeta.

6. Names

No rename wave. A surviving function keeps its name unless the name is wrong.

7. Consumers are never blocked

If a caller needs a kure change to reach a field, the contract is broken. Sugar is added on demand, by the caller’s PR, with its test and golden file. There is no completeness claim and no coverage oracle.

8. Feature-gated and deprecated fields

Ordinary fields. kure cannot know a target cluster’s gates, so withholding a field would be a policy judgement inside a pure library. Maturity is a label, never enforced; it arrives with the generated kinds/scope/maturity tables of the later work item in the builder-contract epic (the current kind registry records scope only).

Identity test

TestIdentity_ConstructorsEmitIdentityOnly walks every kind the scheme registers, calls its generated wrapper and compares the result with reflect.DeepEqual against a zero value carrying only GVK, name and (when namespaced) namespace. Any injected label, selector or default turns it red. TestIdentity_EveryRegisteredKindHasAWrapper fails on a registered kind with no wrapper and on a wrapper with no registered kind.

GVK utilities and scheme

// Lazily registers every supported API group (core K8s, FluxCD, cert-manager, ...)
err := kubernetes.RegisterSchemes()

// Resolve the GVK of any registered runtime.Object
gvk, err := kubernetes.GetGroupVersionKind(myDeployment)

// Check if a GVK is in an allow list
ok := kubernetes.IsGVKAllowed(gvk, allowedGVKs)

Examples

The helpers below are the surviving sugar for the core kinds. Anything not shown is a field write on the upstream struct.

Deployment

dep := kubernetes.CreateDeployment("my-app", "default")
kubernetes.AddLabel(dep, "app", "my-app")
dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}
dep.Spec.Template.Labels = map[string]string{"app": "my-app"}

podSpec := &dep.Spec.Template.Spec
kubernetes.AddPodSpecContainer(podSpec, &corev1.Container{Name: "app", Image: "nginx:1.25"})
kubernetes.AddPodSpecToleration(podSpec, &corev1.Toleration{Key: "dedicated", Value: "web"})
kubernetes.SetDeploymentReplicas(dep, 3)

There is no AddDeploymentContainer. A workload kind’s pod template is a corev1.PodSpec, so the PodSpec helpers serve every kind — pass &dep.Spec.Template.Spec (a CronJob nests one level deeper: &cj.Spec.JobTemplate.Spec.Template.Spec). ServiceAccountName and NodeSelector are plain fields on that struct and are assigned directly.

CronJob

cj := kubernetes.CreateCronJob("my-job", "default")
cj.Spec.Schedule = "*/5 * * * *"
cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyNever

kubernetes.AddPodSpecContainer(&cj.Spec.JobTemplate.Spec.Template.Spec,
	&corev1.Container{Name: "worker", Image: "busybox:1.36"})
cj.Spec.ConcurrencyPolicy = batchv1.ForbidConcurrent

Service

svc := kubernetes.CreateService("my-app", "default")
svc.Spec.Selector = map[string]string{"app": "my-app"}
kubernetes.AddServicePort(svc, corev1.ServicePort{Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080)})
svc.Spec.Type = corev1.ServiceTypeLoadBalancer
kubernetes.AddAnnotation(svc, "external-dns.alpha.kubernetes.io/hostname", "app.example.com")

Ingress

ing := kubernetes.CreateIngress("my-app", "default")
kubernetes.SetIngressClassName(ing, "nginx")

rule := &netv1.IngressRule{Host: "app.example.com"}
pt := netv1.PathTypePrefix
path := kubernetes.CreateIngressPath("/", &pt, "my-app", "http")
kubernetes.AddIngressRulePath(rule, path)
kubernetes.AddIngressRule(ing, rule)
kubernetes.AddIngressTLS(ing, netv1.IngressTLS{Hosts: []string{"app.example.com"}, SecretName: "my-app-tls"})

HPA and PDB

hpa := kubernetes.CreateHorizontalPodAutoscaler("my-app", "default")
kubernetes.SetHPAScaleTargetRef(hpa, "apps/v1", "Deployment", "my-app")
kubernetes.SetHPAMinMaxReplicas(hpa, 2, 10)
kubernetes.AddHPACPUMetric(hpa, 80)

pdb := kubernetes.CreatePodDisruptionBudget("my-app", "default")
kubernetes.SetPDBMinAvailable(pdb, intstr.FromInt32(2))
kubernetes.SetPDBSelector(pdb, &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}})

MinAvailable and MaxUnavailable are mutually exclusive upstream, and each setter writes only the field it names — a helper does not clear a field the caller did not mention. Switching from one to the other is two statements:

pdb.Spec.MinAvailable = nil
kubernetes.SetPDBMaxUnavailable(pdb, intstr.FromString("25%"))

Namespace and Pod Security Admission

ns := kubernetes.CreateNamespace("my-app")
kubernetes.AddLabel(ns, "env", "prod")

// enforce, warn, audit; "" skips a mode, version "" omits the version labels
for k, v := range kubernetes.PSALabels(kubernetes.PSARestricted, kubernetes.PSARestricted, kubernetes.PSARestricted, "v1.28") {
    kubernetes.AddLabel(ns, k, v)
}

PSALabels returns the label map and writes nothing. One argument expanding into six labels is not something a Set<Field> helper may hide, so the expansion is a value helper and the write stays with AddLabel.

ConfigMap

cm := kubernetes.CreateConfigMap("my-config", "default")
kubernetes.AddConfigMapData(cm, "key", "value")
kubernetes.AddConfigMapBinaryData(cm, "cert", certBytes)
kubernetes.SetConfigMapImmutable(cm, true)

// Replacing a map wholesale is an assignment, not a helper
cm.Data = map[string]string{"key": "value"}

// Merging one is a loop over the single-key helper
for k, v := range defaults {
    kubernetes.AddConfigMapData(cm, k, v)
}

SetConfigMapData, SetConfigMapBinaryData, AddConfigMapDataMap and AddConfigMapBinaryDataMap are gone: the first two were bare field assignments, and a bulk merge is not one of the admitted sugar classes in any spelling — neither maps.Copy nor an explicit loop classifies, because the class is a single insert whose value comes from the caller.

PSA security contexts

sc := kubernetes.RestrictedSecurityContext()
psc := kubernetes.PodSecurityContextForLevel(kubernetes.PSARestricted)

err := kubernetes.ValidateContainerPSA(container, kubernetes.PSARestricted)
violations := kubernetes.ValidatePodSpecPSA(podSpec, kubernetes.PSARestricted)

ResourceRequirements

reqs := kubernetes.CreateResourceRequirements()
kubernetes.SetResourceRequest(reqs, corev1.ResourceCPU, resource.MustParse("100m"))
kubernetes.SetResourceLimit(reqs, corev1.ResourceMemory, resource.MustParse("512Mi"))

Two helpers cover every resource name; there is no SetResourceRequestCPU or SetResourceLimitMemory. Both take a parsed resource.Quantity, so the parse — and any error it can raise — belongs to the caller: resource.MustParse for a literal, resource.ParseQuantity when the text comes from configuration.