⚠️ 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"

Every other Go block on this page is the body of an Example function in example_test.go, which go test runs: it imports this package, the upstream APIs under their usual aliases (appsv1, batchv1, corev1, netv1, metav1), k8s.io/apimachinery/pkg/api/resource, k8s.io/apimachinery/pkg/runtime/schema, k8s.io/apimachinery/pkg/util/intstr, k8s.io/utils/ptr, and fmt for the line that prints what the example built.

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"
fmt.Println(d.Kind, *d.Spec.Replicas, d.Spec.Template.Spec.ServiceAccountName)

kure does not provide, and its docs do not suggest, a kure function for plain field access: a helper whose body assigns one argument to one value-typed field is that assignment written twice, and §3’s classifier rejects it.

A whole-spec setter is that same shape one level up, and it is admissible on exactly the same condition — class (b) is decided on the assigned field, not on how deep it sits. Spec is *api.Rule on both Cilium policy kinds, so SetCiliumNetworkPolicySpec and SetCiliumClusterwideNetworkPolicySpec are class (b) and ship; a whole-spec setter for a value-typed Spec would not be admitted, and none exists. These two are the only whole-spec setters in the tree — a value-typed Spec is the caller’s own obj.Spec = spec.

There is no second construction path either. The Kind(&Config) layer — a function per kind taking a kure-invented Config struct and translating it into the upstream spec — was retired by release 2 of the contract, together with the sealed-interface sum types behind it; the release 2 migration notes map every removed field to the upstream field that replaces it. §3 states the rule that keeps it out.

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 ""
fmt.Println(d.APIVersion, d.Kind, ns.APIVersion, ns.Kind)

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.

One case is registered without a wrapper, by name. A wrapper is named after its kind alone, so two versions of one kind in one package would both be Create<Kind>, and the generator refuses that. The version that goes without is listed, with the reason, in skippedWrappers (pkg/kubernetes/internal/kinds/skips.go). It stays registered: a manifest at that version still parses to its typed object and the kind keeps its row in the generated table . A caller that needs to build one names the upstream type: the generic Create[T] above still works for it, since the type is registered. An entry is accepted only when it resolves such a clash — it names a registered kind, gives a reason, and another version of the same kind in the same package keeps the wrapper — so the table cannot be used to drop a constructor nothing else stands in for.

The one entry is MetalLB’s BGPPeer at metallb.io/v1beta1, which upstream deprecates. metallb.CreateBGPPeer returns metallb.io/v1beta2, the version MetalLB stores.

Base kinds covered

A base kind has a wrapper when kure registers the group version that defines it. From k8s.io/api those are core/v1, apps/v1, autoscaling/v2, batch/v1, policy/v1, networking.k8s.io/v1, rbac.authorization.k8s.io/v1, storage.k8s.io/v1, scheduling.k8s.io/v1 (PriorityClass), discovery.k8s.io/v1 (EndpointSlice), coordination.k8s.io/v1 (Lease), node.k8s.io/v1 (RuntimeClass) and admissionregistration.k8s.io/v1 (MutatingWebhookConfiguration, ValidatingWebhookConfiguration, ValidatingAdmissionPolicy, ValidatingAdmissionPolicyBinding, MutatingAdmissionPolicy, MutatingAdmissionPolicyBinding), plus CustomResourceDefinition from k8s.io/apiextensions-apiserver. Registering a group version brings every kind it defines; the generated table lists each one with its scope.

The Flux image kinds are covered with the other Flux kinds, in pkg/kubernetes/fluxcd: ImageRepository and ImagePolicy come from github.com/fluxcd/image-reflector-controller/api, ImageUpdateAutomation from github.com/fluxcd/image-automation-controller/api, all three in image.toolkit.fluxcd.io/v1.

APIService (apiregistration.k8s.io/v1, cluster-scoped) is covered as a built-in whose Go type lives outside k8s.io/api, in k8s.io/kube-aggregator. kure imports that module’s API package only, and the module is pinned to the same release as the other k8s.io modules.

VerticalPodAutoscaler (autoscaling.k8s.io/v1) is out of scope, for three reasons:

  • Its Go type lives in k8s.io/autoscaler/vertical-pod-autoscaler, which is the whole autoscaler component and not an API-only module, and the kind is not part of the Kubernetes API: a cluster serves it only where the autoscaler’s CustomResourceDefinition is installed.
  • Requiring that module moves the versions of 17 indirect dependencies kure shares with its other modules (measured at v1.8.0).
  • Registering its group version also registers VerticalPodAutoscalerCheckpoint, the autoscaler’s internal state, which would get a constructor nobody should call.

What stays available: parse it with AllowUnstructured and supply its CustomResourceDefinition for the scope, or build it with the module’s own types in the calling program.

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 one value to a slice field, or inserts one key into a map field, as F = append(F, v) or F[k] = v: alone in its statement, once per helper, with F spelled from a pointer parameter through selectors, indexes and dereferences, and that parameter never reassigned nor its address taken. Anything else that appends or inserts is refused: an append or insert into a local, a temporary, an alias or an element of a map or slice parameter; F spelled with & or a call; a nested, multi-value or spread append, or one extending another field; an append that is not the whole value of an assignment; ++, op= or a range clause into a map element; a tuple assignment; a second append or insert; and an index key or append base the comparison cannot equate (a call, conversion or slice expression), even when spelled alike;
  • (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 map, slice or pointer field with (if o.Labels == nil { o.Labels = map[string]string{} }) is not such a value. An empty value (an empty literal or make, or new(T)) written into any other field, an interface, a struct or a channel, is one the caller did not supply and is refused as a default, including when it is written through a pointer the helper initialised. A make of a slice with a length other than a constant 0 may fill it with elements the caller did not supply, so it is conservatively not that init; a constant capacity or map size hint is (the grammar below, N1). An increment, decrement or compound assignment of a field (o.Spec.Count++, o.Spec.Count += n) writes a value computed from what the field held rather than the caller’s value, and is inadmissible for the same reason; so is the same write spelled out (o.Labels[k] = o.Labels[k] + v), though extending a slice with o.Spec.Items = append(o.Spec.Items, s) is not. The same holds for a map or slice element reached through a local or a range variable, however it was made (labels := o.Labels; labels[k] += v, for _, items := range o.Spec.Groups): a helper body declares no local but the marshalled form of an argument, and has no loop (the grammar below, S3 and S1).

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 written P or a marshalled local: an append that also sets a scalar to a literal or a computed value (V1), or to &P or *P (V6), touches a field the caller did not name (§4). Forwarding a second argument alongside (SetHPAMinMaxReplicas(hpa, 2, 10)) leaves the class alone, and so does a pointer write beside the one append or insert: each write is checked on its own by the grammar below, and the class is that of the append. 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 a field that can hold it, or as a keyed value inside a literal it writes as a field or inserted value (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). nil is no argument (V1), and the name is read as spelled: a parameter named nil is refused by that name. 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 one pointer parameter, which the body never reassigns and never takes the address of, and every write in every class is spelled from it (the grammar below, P1). A map, slice or interface parameter is not such a root; the metadata helpers of §5 are admitted by name, not by class. A struct taken by value is a copy, so a helper written that way changes nothing the caller can see and is inadmissible; a read-modify-write through such a copy’s map, slice or pointer field still reaches the caller, and is refused because the object is not a pointer parameter (P1).

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 reads a body through the grammar below and nothing else. A loop, a switch, a select, a bare block, a return and a goto are each refused as the statement they are (S1), and a write inside an if is refused unless the if is one of the three guards (S8), so the optional-value guard if name != "" { ... } that §4 forbids is refused, and so is an if that returns or jumps over the write. The one guard that writes is the nil-init guard if P == nil { P = <zero value> } with nothing else in it: no init statement, no else, one statement zero-initialising the map, slice or pointer path it tests (S8, N1). A set-if-unset (if o.Spec.Ref == nil { o.Spec.Ref = ref }) is not that guard and is refused, and neither is a guard filling a nil interface field with an empty value, nor one around a slice make with a non-zero length. No class admits an alias: a conditionally created one (obj := &Obj{}; if ok { obj = o }; obj.Spec.Ref = ref) is refused at its first statement, which is not the one local the grammar below has a form for (S3). A helper containing a function literal (S9), or declaring type parameters, is inadmissible. 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.

The grammar of a helper body

A helper that returns a value or declares type parameters is refused unread. Every other body is read through a closed grammar and nothing else: every top-level statement, every path it writes and every value it writes has one of the forms below, and the first rule a body breaks is the verdict, which names the rule: <what was found> (grammar S3, purity §4). What was found is printed as written, parentheses and the elements of a composite literal included, so two paths that differ only inside a literal, d.Plans[len([...]int{1})] and d.Plans[len([...]int{1, 2})], read apart in the reason that names both. The class of a body the grammar admits is the class of its writes: (a) when one appends or inserts, else (b) when one is pointer-typed, a nil-init included, else (c) when one writes a struct literal of two or more fields or a nested one; a body with none of these forwards its arguments bare, and one with no field write delegates or does nothing. The four helpers admitted by name (§5) are not read.

A body is nil guards and marshalled locals first, then nil-init guards and field writes. The object is the parameter the first write in the body is spelled from. Parentheses change nothing, and names are resolved by what they declare: a local or a method called panic or Marshal is not the builtin or the function. Where a rule reads as written instead, it says so: N1, P6, V6 and V7 compare two paths, and V1 reads the name nil.

IDStatements, at the top level of the body
S1A statement is an if or an assignment. A call statement, var, defer, go, a send, a loop, a switch, a select, a bare block, a label, ++, --, a return, a goto and an empty statement are refused.
S2Nil guards that panic, locals and the guards on their errors come before the first nil-init guard or field write.
S3A := is exactly v, err := json.Marshal(P): two new names, neither blank, and P a parameter other than the object.
S4The statement directly after a local is the guard on its error, if err != nil { panic(M) }, and that guard appears nowhere else.
S5A panic takes one argument: a string constant, or in the guard on an error fmt.Sprintf(c, err) with c a string constant.
S6A nil guard that panics, if P == nil { panic(c) }, tests the object, or a parameter the body writes as *P. On any other parameter it makes nil unexpressible, which is validation (§4).
S7Any other assignment has one target, one value and the token =: no tuple, no op=.
S8An if has no init statement, no else and a one-statement body, and is one of three guards: the nil guard that panics, the guard on an error, the nil-init guard. X == nil and nil == X are the same test. An if that holds a return or a goto is refused as what it is: a write skipped on some paths.
S9The body holds no function literal, wherever it stands. No rule reads the body of a closure, so it could hide any write; a constant that holds one, len([1]func(){func() {}}), is refused too.
S10At most one statement appends or inserts. Class (a) is a single append or insert; a second one, into the same field or another, is refused at the statement that makes it.
S11Every call of append is the whole value of an assignment. One anywhere else is no class (a) statement, whatever it extends: in a var, as an argument, sliced, nested in another append, or inside a constant, unsafe.Sizeof(append(…)).
IDPaths: the target of a field write or of a nil-init, and the slice an append extends
P1A path is spelled from the object through field selectors, indexes and dereferences, and the object is a pointer parameter the body never reassigns and never takes the address of.
P2A path names a field: *o = x is refused.
P3A key or index is a parameter other than the object, or a constant.
P4No location is written twice, and none is written after a write to a location above or below it on the same path. Every pointer followed counts as a step of the path, written or not; a promoted field counts as its full spelling; any two keys may name the same element. The one exception is a nil-init guard ahead of the write it initialises, on the same path with the same keys: the same parameter, or constants of one type and one value, however each is spelled.
P5A nil-init guard initialises a field, not an element of a map or slice.
P6Every nil-init is written through: by a later write below it, or by the append that extends it. A pointer nil-init is written through by a later write spelled <its path>.… or *<its path>, as written, and any such write counts: (*o.Spec.Ref).Name, a promoted field of an embedded pointer and an index of a pointer to an array are not that spelling. A write below a parenthesised nil-init, (o.Spec.Ref) = &Ref{}, counts only when it is spelled the same way: (o.Spec.Ref).Name is, o.Spec.Ref.Name is not.
P7Behind a nil-init guard a write meets only what that guard assigned: one pointer, one map or one empty slice. As its first step there it may follow that pointer or index that map. Past that step it follows no pointer and indexes no map, because what lies behind holds its zero value, and it indexes no slice at all: o.Spec.Items[i] = v behind o.Spec.Items = []string{} has no element to write. An append needs no slice, and an array has its elements. The guard is the nearest one ahead of the write on the same path, and a nil-init guard is itself such a write behind the guard ahead of it. The rule reads the path, not the value, so the map behind &map[K]V{} is not indexed either.
N1A nil-init guard tests the path it assigns, spelled alike outer parentheses aside and with the same keys as under P4: if (*o).Spec.Ref == nil { o.Spec.Ref = &Ref{} } is refused. The path is a map, slice or pointer; an empty value in an interface or a channel is a default. The guard assigns T{}, &T{}, new(T) or a make with constant sizes. A slice make has length 0: make([]T, 3) allocates three elements the caller did not supply.
IDValues: the element appended, the value inserted, the value of a field
V1A value is an argument passed whole, a struct literal, or the address of one. An argument is a parameter other than the object, written P, &P or *P, or the first name of a local; the error of a local is never a value. A struct literal, keyed or positional, holds arguments, constants, struct literals and their addresses. The name nil, as spelled, is not the value of a field that can hold nil, nor a keyed element of a literal written as a field or inserted value, at any depth reached through keyed elements: a parameter named nil is refused by that name. A positional element is not read for it, whatever it holds, and neither is an appended element.
V2A struct literal is the element appended or inserted, &T{...} written to a pointer-typed field, or the value of the helper’s only field write (nil-init guards not counted). A struct replaced beside another write loses fields the caller did not name.
V3A constant in a struct literal is a named constant of a defined type (corev1.ProtocolTCP). An inline literal ("Deployment", true, 3) or a constant of a basic type is a default.
V3bEvery struct literal, at every depth, carries an argument.
V4Each argument occurs once over all written values and marshalled locals: &n written to two fields gives them one pointer, and so does T{A: p, B: p}. Keys and guards do not count.
V5Every parameter has a place: it is the object, a key or index of a path, or an argument written or marshalled. A parameter the body leaves out, a blank one included, is a value the caller passed and the helper dropped.
V6&P and *P are the argument as the body took or followed its address, not as the caller passed it. Each is the element appended or inserted, the value of a pointer-typed field, or written to a path spelled through a pointer-typed field written earlier, <its path>.… or *<its path>, as written. Anywhere else it writes a field the caller did not name: o.Spec.Count = *c beside o.Spec.Replicas = &n is refused.
V7No written value reads its target, the value of a nil-init included, wherever the read stands: in a constant (unsafe.Sizeof(o.Spec.Items)), in the size of a make, or in the type of a literal. The target is read as written, parentheses aside: unsafe.Sizeof(o.Spec.Payload) in a value written to (*o).Spec.Payload is not a read of it. A value computed from what the field held is not the caller’s. The slice an append extends is not such a read.

A call therefore appears in six positions only: append as the whole value of a class (a) statement, make and new in a nil-init guard, json.Marshal in a local, fmt.Sprintf in the message of the guard on an error, and panic as the body of a guard. A constant is a constant however it is spelled (int32(0), len("ab")), and holds no function literal (S9), no append (S11) and no read of the target (V7); any other call, the conversion of an argument included, refuses the helper.

S9, then S11, are read off the whole body first. Then the statements are read in source order, and the first rule a statement breaks names the refusal: S1; for an if S8, S2, S5, S4, and for a nil-init guard P1, P2, P3, P5, N1, V7; for a := S3, S2, S4; for any other assignment S7, P1, P2, P3, S10, V1, V3, V3b, V2, V6, V7. A body read to its end with no field write is refused as a delegation or no-op. S6, P4, P6, P7, V4 and V5 are then read off the whole body, in that order, and the class off its writes.

What the grammar does not see: an alias the caller made (a *P or P that points into the object, or two pointer fields of the object sharing one struct); the caller’s code that json.Marshal and %v run; whether a constant key or index is the one the caller would have chosen; and whether the helper’s name matches the field it writes. Those stay with review and with the helper’s golden test.

A second rule covers what a helper takes, not what it writes: no exported function under pkg/kubernetes/... takes a kure-defined type where an upstream spec type exists. A kure-declared struct or interface as a parameter is a second vocabulary for an object the upstream struct already describes — the shape of the retired Kind(&Config) layer — and TestAdmission_NoOwnParameterTypes fails on one, with no exclusion list. It looks through every layer a type can hide behind: pointer, slice, array, map and channel layers, a callback’s parameters and results, an alias or a named container declared under pkg/kubernetes (type Config = *struct{...}, type List []struct{...}), and a struct or method-bearing interface literal written in the signature itself, and the type arguments of an instantiated generic or generic alias (up.Wrapper[Config]), and the constraint of a type parameter, embedded types and union terms included ([T Variant], [T interface{ Config | *Config }]). It does not enter an upstream named type otherwise. A named scalar with no upstream counterpart (PSALevel, a string enum) and an interface with no methods (any) are not spec types and pass. Like the sugar classifier, the check guards against the layer drifting back in through ordinary authorship; a signature written to evade it is a review matter, not a gap in 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:

obj := kubernetes.CreateDeployment("web", "default")

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")
fmt.Println(obj.Labels["app"], obj.Labels["tier"], obj.Annotations["note"])

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: kure reports what the pinned API sources say and a consumer with cluster knowledge decides.

The label is worth carrying because the failure it describes is silent. For built-in types the API server does not reject a field whose feature gate is disabled — it clears the field and admits the object, so the manifest reads as applied and is not.

pkg/kubernetes/internal/maturity walks every type reachable from a registered kind’s own struct and records the fields carrying a signal: a +featureGate marker, or a doc comment declaring alpha, beta or Go’s conventional Deprecated: prefix. The marker is the precise signal; the prose scan is the best-effort complement for fields upstream documents without gating, and it matches whole words, so “alphabetical” is not alpha.

The prose scan reads prose only — marker lines are removed before it runs. A marker’s words describe that marker’s own subject, not the field’s maturity, and the declarative-validation markers k8s.io/api introduced in v0.37.0 make the difference load-bearing: they are spelled +k8s:alpha(since: "1.37")=+k8s:required, which says the required rule is alpha since 1.37, on fields that have been GA for years. Reading those as prose reported 45 long-stable built-in fields as alpha or beta — StatefulSetSpec.selector, ClusterRole.rules, Secret.type among them — and contradicted a genuinely stable field in the same type. A field’s maturity is claimed in its documentation or by a +featureGate, and is never inferred from another marker.

The same rule decides what counts inside the prose. A mention of “alpha” or “beta” is read as a claim only when the sentence attributes that level to the field being documented — “This is an alpha field”, “This field is beta-level”, a leading “(Alpha)” tag, “Alpha, gated by …”. The words also appear in prose that says nothing about the documented field’s maturity, and counting every occurrence published five such fields as alpha:

  • CustomResourceDefinitionSpec.versions, whose prose explains that a version name ends in “optionally the string alpha or beta and another number” and that names sort “by GA > beta > alpha” — version-name tokens.
  • FileKeySelector.key, a required field with no gate marker, documenting a size limit that applies “During Alpha stage of the EnvFiles feature gate” — the gate’s stage, and a size limit.
  • CommonPrometheusFields.scrapeConfigSelector and its namespace counterpart, which note that the ScrapeConfig custom resource definition “is currently at Alpha level” — another resource’s level.
  • GRPCRouteRule.filters, which says the rule “can change in the future based on feedback during the alpha stage” — a statement about future changes.

The same shape reached the table once through HorizontalPodAutoscalerSpec.minReplicas, GA as long as autoscaling/v2 has been, whose prose says a value of 0 is allowed “if the alpha feature gate HPAScaleToZero is enabled”: the gate’s level, not the field’s. A field whose own maturity is alpha says so in a sentence of its own, and that still counts — including when the same doc comment also names an alpha gate. A construction the list does not cover reports stable, which under-reports rather than publishing a wrong level; the gate list is the signal to act on either way.

Status types are not entered. A kind’s status is reported by the cluster and never constructed by a caller, so a gate there says nothing about whether a manifest kure builds applies as written — and that is where most of the markers are.

Against the current pins, the walk finds 129 maturity-carrying construction-side fields, of which 43 require a feature gate (42 in k8s.io/api, one in k8s.io/apiextensions-apiserver); 25 are documented alpha, 14 beta and 67 deprecated, the remaining 23 are gated without a documented stability claim, and 97 distinct status types are skipped. No CRD module kure pins uses +featureGate at all. These numbers move with the pins and are not asserted by any test; the pins themselves are not restated here — every generated row carries the module and version it was read from, and the exact build versions live in the generated Compatibility Matrix . What the tests assert is that every reported field exists in the pinned struct, that a set of long-GA built-in fields is reported stable, and that the five fields named above — the ones whose prose mentions the words about something else — claim nothing.

9. Where scope and maturity come from

Both are derived from the pinned upstream module sources, not kept by hand beside them. Five internal packages do it — four under pkg/kubernetes/internal, one at the module root — and none of them is part of the public API:

  • internal/markers parses the controller-gen markers kure reads — +kubebuilder:resource for scope, +featureGate for maturity. Pure text, no I/O.
  • internal/upstream loads the pinned modules with go/packages and returns each named type with its doc comment, fields, file-scoped import aliases and the module, version and module directory it came from. A field tagged json:"-" is left out: it is not serialised, so it has no name a manifest could carry, and recording it would file it under the name - and make its type reachable in the maturity walk (cilium’s XDSResource embeds *anypb.Any that way).
  • internal/crds (the module root’s, github.com/go-kure/kure/internal/crds) reads the CustomResourceDefinition manifests a module ships in that directory, which is where the scope comes from for a type carrying no marker. Each module directory is walked once per process and its index kept: the walk decodes every manifest the module ships, and a module-cache directory never changes under a fixed version. A walk that fails is not kept. The same reader returns whole definitions, which is what the schema validation test below holds kure’s output to.
  • internal/kinds resolves a scope per registered kind; internal/maturity walks the type graph for the field table.

Every resolved scope records which of three sources answered — marker, builtin or crd, surfaced on KindInfo.ScopeSource — so a wrong scope can be traced to the thing that claimed it. Against the current pins that is 67 from the kind’s own marker, 55 from the built-in table and 20 from a shipped CRD. The source is part of the public answer and not only a debugging aid: builtin is what makes a kind a built-in, so a caller asking specifically about built-ins reads it rather than keeping a list.

kinds.Registered() returns each kind with that resolution already applied, and it is the only place a scope is stated: the 128-entry hand-seeded pair of sets this package used to carry is gone, and so are the two hand-kept maps in pkg/manifest, which now reads scope from the generated table — restricting to ScopeSourceBuiltin where it asks about built-ins specifically. The cluster-scoped half of the old table survives as a frozen fixture in the internal/kinds tests, dated to the pins it was taken at. That is deliberate: the derivation fails silently by construction — an absent, unread or detached marker resolves to Namespaced, which is also the right answer for 100 of the 142 kinds — so without a literal to compare against, a regression in the comment reattachment below would turn cluster-scoped kinds namespaced with nothing going red. A pin bump that legitimately re-scopes a kind is an edit to that fixture, made with the upstream change named in the commit message. So is registering a built-in group version that defines a cluster-scoped kind: its entry goes into the fixture and into the built-in table in the same change.

Three things about that derivation are worth knowing before changing it.

A marker the parser cannot read is a fatal error, never a default. The one legitimate default — an absent +kubebuilder:resource:scope, which upstream defines as Namespaced — is indistinguishable from a marker whose spelling the parser failed to match. Treating the second as the first emits a namespaced wrapper for a cluster-scoped kind and puts a metadata.namespace on an object that must not carry one. Two spellings are in use across the pinned modules and both are accepted: the bare scope=Cluster, and scope="Cluster" quoted inside a comma-separated list whose other values contain braced commas.

controller-gen separates its marker block from the type’s prose doc comment with a blank line, which detaches the markers from the declaration’s Doc in go/ast and files them as free-floating comments on the file. Reading Doc alone derived 31 of the 33 kinds that were cluster-scoped when this was measured as namespaced — silently, since every wrong answer is the default. internal/upstream reattaches the preceding comment group, bounded by the end of the previous declaration, and drops it for grouped type (...) blocks where it cannot be attributed to one spec.

Many types carry no +kubebuilder:resource marker at all, and none of them gets the default handed to it:

  • The built-in modules (k8s.io/api, k8s.io/apiextensions-apiserver, k8s.io/kube-aggregator) have no markers because the API server, not a generator, defines their scope. Twenty-five explicit entries in internal/kinds name the cluster-scoped built-ins; every other built-in kind is namespaced.

  • A CRD module marks a type only when it needs a non-default setting, so an unmarked root type is ordinary rather than exceptional: 20 of the registered kinds are in that state, across cnpg, metallb and plugin-barman-cloud. Their scope is read from the CustomResourceDefinition the module itself ships — controller-gen’s own output, generated from the same source, which states the scope explicitly whether or not a marker was needed to produce it. That is a second upstream source, not a second guess, and it keeps the answer out of a table maintained here.

    Only final manifests count. A file is read when a kind: key at the start of a line names a CustomResourceDefinition, and it is skipped when it contains Go template delimiters: a Helm template is the input to a rendering step, not a definition. metallb ships exactly that shape — a chart whose crds.yaml opens with real CRDs and later uses {{ .Release.Namespace }} as a map key — and its real manifests sit in config/crd/bases beside it. Within a file that is read, a document that does not decode is an error naming the file and the document’s index, never a short read: stopping quietly there drops every definition after it, which loses a kind’s only answer or one half of a scope conflict with nothing to show anything was skipped.

    Those files are read out of the module zip as unpacked in the local module cache — the directory go list -m reports for the pinned version — not fetched from the project’s repository. So the manifests read are exactly the ones the pinned version publishes, and they are covered by the module checksum like any other file in it. The consequence to know: a module that stops shipping its CRDs in a later version turns the next Renovate bump into a hard generation failure on that PR, which is the intended behaviour — the scope becomes unanswerable and is declined rather than silently defaulted to Namespaced.

  • A kind with neither a marker nor a shipped CRD is an error. The namespaced default and a marker that was not read give the same answer, so a kind nothing can answer for is a question kure declines rather than resolves. Both halves are probed by fixtures: a partly-marked module (one root marked, one not — also the shape a grouped type (...) block produces), a shipped CRD declaring Cluster for an unmarked kind, and a module whose CRDs do not cover the kind at all.

10. The generated tables

The derivation above is committed as three artifacts, all written by pkg/kubernetes/internal/gen from one derivation pass so they cannot disagree:

ArtifactFor
zz_generated_tables.gothe two Go tables, read through Kinds() and FieldMaturities()
docs/api-tables.jsonthe machine-readable copy, read as a diff on a bump PR
docs/api-tables.mdthe site page (mounted via site/docs-map.yaml)

tables.go holds the hand-written types and lookups over them — KindFor, KindForAnyVersion, KindByGroupKind, IsNamespaced, MaturityForType, GatedFields. Every value is a plain string or bool: the generator writes into this package, so a table that referenced an upstream type could not be regenerated after an API bump removed it. Each kind row names the module and version it was read from, and ScopeSource names what stated the scope — ScopeSourceMarker, ScopeSourceShippedCRD or ScopeSourceBuiltin — so any row is traceable to a pin, and a caller can tell a built-in kind from a custom resource without keeping a list of its own. pkg/manifest’s IsNamespacedBuiltinKind is exactly that: a scope answer restricted to ScopeSourceBuiltin.

The tables themselves are unexported, and Kinds() and FieldMaturities() hand back copies — deep ones, since a row’s Gates slice would otherwise stay shared with the table. The lookups read the tables on every call, pkg/manifest’s scope resolution among them, so a consumer that edited a returned row in place to relabel a kind for its own output would change the answer every later caller in the process gets, at a distance and with nothing pointing back to the edit.

The kind lookups answer deliberately different questions, and the difference is the version:

  • KindFor(apiVersion, kind) matches the version as well as the group. GoType, ImportPath and ModuleVersion describe one version’s Go type, so a group/kind registered at some other version is not an answer — it reads as unregistered, which is what it is.
  • KindForAnyVersion(apiVersion, kind) does not require the version to be registered, and is the row behind the version-insensitive answer: scope. It returns the row for the exact version when kure registers it, and otherwise the row KindByGroupKind returns. That row may describe a different version than the one asked about, so anything version-specific on it is not an answer to the question that was asked. ScopeSource is one of those: it says what declared the scope for the returned row’s own type.
  • KindByGroupKind("group/Kind") returns one row for the group/kind. A group/kind registered at two versions has two rows — MetalLB’s BGPPeer, at metallb.io/v1beta1 and metallb.io/v1beta2 — and this returns the first in table order, the lowest version string, which is not necessarily the stored one. Read from it only what the versions share, which is the scope.
  • IsNamespaced(apiVersion, kind) ignores the version. Scope is a property of the resource and is the same across the versions of one group/kind, so a manifest written against a version kure does not register is still answered rather than returned as unknown. pkg/manifest’s Scope depends on that: an object at autoscaling/v1 must not fall through to ScopeUnknown because the scheme happens to register autoscaling/v2. The generator holds the tables to it: two registered versions of one group/kind that resolve to different scopes are a generation error, not two rows that disagree.

Regenerate with scripts/gen-builders.sh generate; CI’s validate job runs scripts/gen-builders.sh check, and Renovate runs generate in its postUpgradeTasks so a bump PR arrives with the tables already updated. Do not edit the artifacts by hand.

Two consequences of that wiring:

  • A pin bump that changes a table changes a file in a doc-gated package. When a row’s only change is its ModuleVersion provenance field — pure version churn — the doc-gate exempts it automatically, needing neither a paired doc edit nor the maintainer docs-skip label. A change that adds, removes, or re-scopes a kind is not version churn and still trips the gate. See docs/dependency-updates.md.
  • recover does not delete zz_generated_tables.go. It holds no upstream type names, so an API bump cannot make it uncompilable, and tables.go reads the values it declares. Its absence is a compile error on purpose: an empty table would report every kind as unregistered, which is a wrong answer rather than a missing one.

Identity test

TestIdentity_ConstructorsEmitIdentityOnly walks every generated wrapper, calls it 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. A kind named in skippedWrappers is the exception in both directions: it must have no wrapper, and one that has is a failure too.

Schema validation test

Every golden fixture under a family’s testdata/, the Flux bootstrap and Kustomization output pkg/stack/fluxcd emits in its tests, and every manifest the getting-started example writes are validated as the YAML a user would apply: against the CustomResourceDefinition of the exact module version the kinds table pins, with the API server’s own create-time routines — schema, defaults, pruning of unknown fields, ObjectMeta, list invariants and the x-kubernetes-validations CEL rules — in process and offline. The helper is internal/kuretest: Golden validates before it compares or rewrites a fixture, so -update can never write one a cluster would reject; AssertValid, AssertValidYAML and AssertValidDir take objects, bytes and a written tree. AssertConsistent, AssertConsistentYAML and AssertConsistentDir take the same three and hold the objects to each other instead: every Flux reference resolves and every namespace exists, in the tree or declared by the test. On a written tree, every path it names resolves too, and what each Flux Kustomization applies builds.

TestEveryKindHasOneSource names every registered kind as schema-backed or uncovered, with the reason. Schema-backed kinds are the ones whose module ships its definitions (cert-manager, cilium, cloudnative-pg, plugin-barman-cloud, flux-operator, gateway-api, metallb, volsync) or whose definitions the vendored Flux install bundle carries (internal/gotk): 76 against the current pins. Uncovered kinds have no definition to hold them to — the built-in kinds of k8s.io/api, k8s.io/apiextensions-apiserver and k8s.io/kube-aggregator, whose validation the apiserver implements in Go, and the external-secrets and prometheus-operator kinds, whose API modules ship no manifests — and get the server’s ObjectMeta validation under the kind’s own name rule, and nothing more: 66 against the current pins. A kind in neither table, a stale table entry, a definition whose scope disagrees with the kinds table, or an uncovered module that starts shipping definitions all turn the test red. Why the validation is in process and what it leaves to a real apiserver is recorded in docs/history/20261001-DESIGN-schema-validation.md.

GVK utilities and scheme

myDeployment := kubernetes.CreateDeployment("web", "default")
allowedGVKs := []schema.GroupVersionKind{appsv1.SchemeGroupVersion.WithKind("Deployment")}

// Lazily registers every supported API group (core K8s, FluxCD, cert-manager, ...)
err := kubernetes.RegisterSchemes()
if err != nil {
    panic(err)
}

// Resolve the GVK of any registered runtime.Object
gvk, err := kubernetes.GetGroupVersionKind(myDeployment)
if err != nil {
    panic(err)
}

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

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)
fmt.Println(podSpec.Containers[0].Image, podSpec.Tolerations[0].Key, *dep.Spec.Replicas)

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 Job uses &job.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.

Job

job := kubernetes.CreateJob("migrate", "default")
job.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyNever

kubernetes.AddPodSpecContainer(&job.Spec.Template.Spec,
    &corev1.Container{Name: "migrate", Image: "busybox:1.36"})
fmt.Println(job.Spec.Template.Spec.RestartPolicy, job.Spec.Template.Spec.Containers[0].Name)

CreateJob writes identity only, so restartPolicy is yours to set. Leave it out and the API server defaults it to Always, which it then rejects for a Job pod (restartPolicy: Unsupported value: "Always"); set Never or OnFailure. The CronJob example below sets it for the same reason.

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
fmt.Println(cj.Spec.Schedule, cj.Spec.ConcurrencyPolicy)

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")
fmt.Println(svc.Spec.Ports[0].Port, svc.Spec.Ports[0].TargetPort.IntValue(), svc.Spec.Type)

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"})
fmt.Println(*ing.Spec.IngressClassName, ing.Spec.Rules[0].Host, ing.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name)

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"}})
fmt.Println(*hpa.Spec.MinReplicas, hpa.Spec.MaxReplicas, *hpa.Spec.Metrics[0].Resource.Target.AverageUtilization, pdb.Spec.MinAvailable.IntValue())

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 := kubernetes.CreatePodDisruptionBudget("my-app", "default")
kubernetes.SetPDBMinAvailable(pdb, intstr.FromInt32(2))

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

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)
}
fmt.Println(len(ns.Labels), ns.Labels["pod-security.kubernetes.io/enforce"])

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

certBytes := []byte("-----BEGIN CERTIFICATE-----")
defaults := map[string]string{"log-level": "info"}

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)
}
fmt.Println(len(cm.Data), cm.Data["log-level"], *cm.Immutable)

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

container := &corev1.Container{Name: "app", Image: "nginx:1.25", SecurityContext: kubernetes.RestrictedSecurityContext()}
podSpec := &corev1.PodSpec{Containers: []corev1.Container{*container}}

psc, err := kubernetes.PodSecurityContextForLevel(kubernetes.PSARestricted)
if err != nil {
    panic(err)
}
podSpec.SecurityContext = psc

err = kubernetes.ValidateContainerPSA(container, kubernetes.PSARestricted)
fmt.Println(*container.SecurityContext.RunAsNonRoot, err)
err = kubernetes.ValidatePodSpecPSA(podSpec, kubernetes.PSARestricted)
fmt.Println(err)

ResourceRequirements

reqs := kubernetes.CreateResourceRequirements()
kubernetes.SetResourceRequest(reqs, corev1.ResourceCPU, resource.MustParse("100m"))
kubernetes.SetResourceLimit(reqs, corev1.ResourceMemory, resource.MustParse("512Mi"))
fmt.Println(reqs.Requests.Cpu(), reqs.Limits.Memory())

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.