Builder Contract
Builder Contract
kure’s Kubernetes foundation is deliberately thin: it hands you the upstream Go struct with its identity filled in, and stops. This page is the consumer’s view of what that means in practice — how to build an object, when a kure helper exists and when there is none, how to read the generated maturity table, and why the layers above the foundation state their opinions as names you can grep for.
The normative text is the Kubernetes Builders
page, which is
pkg/kubernetes/README.md mounted here. Where this page and that one differ, that one is right.
Building an object
Every registered kind has a constructor, all but one of them under the name Create<Kind>. It
returns the upstream type, carrying apiVersion,
kind, metadata.name and — for a namespaced kind — metadata.namespace. Nothing else.
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)Every Go block on this page is the body of an Example function that go test runs: the
defaults block below is in pkg/stack/fluxcd/builder_contract_example_test.go, the others are in
pkg/kubernetes (example_test.go and builder_contract_example_test.go). They import
github.com/go-kure/kure/pkg/kubernetes, appsv1 (k8s.io/api/apps/v1), k8s.io/utils/ptr and
github.com/go-kure/kure/pkg/stack/fluxcd, and add fmt.Println lines that print what they built.
Cluster-scoped kinds take one argument, and the signature is how you tell:
ns := kubernetes.CreateNamespace("platform")
fmt.Println(ns.Kind, ns.Name, ns.Namespace == "")The wrapper is a spelling, not a separate implementation: it calls the generic form, which you can write directly when naming the type reads better than naming the kind.
d := kubernetes.Create[appsv1.Deployment]("web", "default")
fmt.Println(d.APIVersion, d.Kind, d.Namespace, d.Name)The wrappers are generated from the registered scheme, so the set of constructors and the set of kinds kure knows about cannot drift apart. An unregistered type panics — that is a programming error, like a nil receiver, not a runtime condition to handle.
One registered kind has no wrapper of its own. MetalLB serves BGPPeer at two versions, a wrapper
is named after its kind alone, and metallb.CreateBGPPeer builds metallb.io/v1beta2, the version
MetalLB stores. The deprecated metallb.io/v1beta1 is still registered, so the generic form builds
it: kubernetes.Create[metallbv1beta1.BGPPeer](name, namespace). The
Kubernetes Builders
page states the rule.
What a constructor will not do
It will not set a label, a selector, a replica count, a schedule, an access mode or an empty slice.
Earlier releases did some of this: CreateDeployment wrote app: <name> into both the labels and
the selector, CreatePersistentVolumeClaim requested 1Gi, CreateServiceAccount wrote
automountServiceAccountToken: false. All of it is gone, and the
release-1 migration notes
list every value with the line
that restores it.
The reason is that a default you did not ask for is indistinguishable, in the YAML, from one you did. A 1Gi PVC that you never sized reads exactly like a 1Gi PVC you sized deliberately, and the difference only becomes visible when the volume fills.
When sugar exists, and when it does not
A Set* or Add* helper survives only if the write it performs falls into one of three classes,
and pkg/kubernetes/admission_test.go parses every exported helper in the tree and fails the build
on one that fits none of them. The classes are about the shape of the write, not about whether you
could have written it yourself: a pointer field you already hold a pointer to is a one-line
assignment, and a helper for it is admitted all the same.
There is one exception, and it is four names rather than a rule. SetLabels, AddLabel,
SetAnnotations and AddAnnotation write through the metav1.Object interface rather than into a
field, so they match no write shape and are admitted by name — a fourth outcome (Exempt) in the
classifier, listed in pkg/kubernetes/admission_test.go and, unlike an exclusion list, one that
never grows. That is what lets them cover every kind, including kinds kure has never heard of.
| Class | What the helper does | Example |
|---|---|---|
| Appender | Appends to a slice field, or inserts into a map field — either may be nil, so writing it by hand is a read-modify-write with a guard | AddPodSpecContainer, AddRoleRule, AddConfigMapData |
| Pointer / nil-init | Assigns to a pointer-typed field, or initialises a nil pointer intermediate before writing through it. Taking the address of a value argument needs a named local; forwarding a pointer the caller already holds does not, and both count | SetDeploymentReplicas, SetPodSpecAffinity |
| Composite | Writes several fields that belong together — two or more fields of an upstream struct, or a nested literal — and names the opinion | SetHPAMinMaxReplicas, AddHPACPUMetric |
Everything else you write yourself:
d := kubernetes.CreateDeployment("web", "default")
// There is no SetDeploymentStrategy. This is what it would have done.
d.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}
fmt.Println(d.Spec.Strategy.Type)That is the contract working as intended, not a gap. A helper whose whole body is
obj.Field = value is the assignment with a longer name and one more thing to keep in sync with
upstream — and there were 257 of them before release 1.
Two consequences are worth planning around:
- Helpers take the pod template’s
PodSpec, not the workload kind. There is oneAddPodSpecContainer, not one per workload:AddPodSpecContainer(&d.Spec.Template.Spec, c), and for a CronJobAddPodSpecContainer(&cj.Spec.JobTemplate.Spec.Template.Spec, c). - Metadata is one set of helpers over every kind.
SetLabels,AddLabel,SetAnnotationsandAddAnnotationtake ametav1.Object, so they work on kinds kure has never heard of. Per-kind label helpers do not exist.
Sugar returns nothing and panics on a nil object. If you need a field a helper does not reach, assign it; if you need a helper that does not exist, the contract says to send the PR — there is no completeness claim to wait on.
Reading the maturity table
Supported kinds and field maturity is generated from the upstream modules this build pins, and every row names the module and version it was read from. It has two tables.
Kinds answers “does kure know this kind, and is it namespaced?” The Scope from column says
what settled the scope — the kind’s own +kubebuilder:resource marker, the
CustomResourceDefinition its module ships, or the built-in table for kinds whose scope the
Kubernetes API defines. A kind no source can answer for is a generation-time error, never a
namespaced default.
Field maturity lists construction-side fields carrying a stability signal: a +featureGate
marker, or a doc comment that claims alpha, beta, or Go’s Deprecated: prefix for the field being
documented. Read it as a report, not a warning:
- kure never filters or refuses on this. It cannot know your cluster’s gates, so withholding a field would be a policy judgement inside a library.
- The
Feature gatescolumn is the one to act on. For a built-in type, the API server does not reject a field whose gate is off — it silently clears the field and admits the object. The manifest reads as applied and is not, which is the failure this column exists to make visible. - A blank stability with a gate listed means upstream gates the field but does not document a level. That is still a gate, and still silent.
- Status subtrees are excluded. The walk records a field, then declines to descend into a type
whose name ends in
Status— a status is reported by the cluster, never built by a caller, and that is where most of the markers live. Two edges follow from how that exclusion is done: thestatusfield itself is recorded if upstream marks it, since the field is read before the descent is refused, and a status type upstream names something else is walked like any other. So read the exclusion as “status subtrees, recognised by name”, not as a guarantee that nothing status-shaped can appear.
The numbers move with the pins, by design: bump a module and the tables are regenerated, so “what does kure support” is answered from the pin rather than from prose someone forgot to update.
Opinions as nouns
The foundation applies no opinion you did not ask for. It is allowed to hold one: a composite
like AddHPACPUMetric(hpa, 80) encodes real knowledge about how a CPU metric is shaped, and the
contract admits it precisely because you have to name it to get it — the opinion arrives with the
call, never with the constructor. What the foundation never does is default, and every composite
carries a golden test of its complete output so an injected value shows up in the diff that adds it.
The workflow layer above it — pkg/stack/fluxcd — goes further and defaults outright: something
has to decide a reconcile interval, a namespace, a source kind. The rule there is not “have no
defaults”, it is every default is a name you can see.
// declared in pkg/stack/fluxcd/defaults.go
fmt.Println(fluxcd.DefaultInterval) // 60 * time.Minute
fmt.Println(fluxcd.DefaultNamespace) // "flux-system"
fmt.Println(fluxcd.DefaultSourceKind) // "OCIRepository"Eleven exported identifiers now stand where sixteen anonymous literals were, several of which were the same value written in two places and could therefore have drifted apart. For a consumer this buys three things:
- Discoverability — the package’s opinions are a file you can read, not a value you find by diffing generated YAML against what you expected.
- Grep-ability — one identifier tells you every place a value can reach emitted YAML.
- Override, explicitly — four are copied into exported generator fields, four yield to a named input, and three are fixed path segments with no override. The three that cannot be overridden now say so, instead of being documented as though they could.
The same principle runs through the foundation in negative: an opinion that has no name and no owner does not get to exist. That is why the constructor defaults were removed rather than documented.
Where to go next
- Kubernetes Builders — the normative contract, with the admission classes and the derivation in full
- Release-1 migration notes — every removed helper and the expression that replaces it
- Supported kinds and field maturity — the generated tables
- Using Kure as a Library — import paths and end-to-end examples