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

Builder Contract Migration

Builder contract: release 1 migration notes

This page tracks what the builder contract (ADR-038, the “thin core + admissible sugar” decision) changes for callers across its first release. The normative contract text is the Kubernetes Builders page; this page is the ledger of removed behaviour, kept so each work item of the epic lists its own removals exactly once.

Constructors are identity-only

Every Create<Kind>(name, namespace) (or Create<Kind>(name) for cluster-scoped kinds) now returns an object carrying only apiVersion, kind, metadata.name and, for namespaced kinds, metadata.namespace. The wrappers are generated from the registered scheme; the generic kubernetes.Create[T] behind them is the single implementation.

Two signatures changed:

BeforeAfterSet the removed value with
CreateCronJob(name, namespace, schedule)CreateCronJob(name, namespace)cj.Spec.Schedule = schedule
CreateIngress(name, namespace, classname)CreateIngress(name, namespace)SetIngressClassName(ing, classname)

Constructor defaults removed by the contract PR

The hand-written constructors injected the values below. They are gone; a caller that relied on one sets it explicitly. The defaults work item of the epic owns every other default (composite helpers, the Flux workflow packages) and does not list these again.

Metadata injected by every root constructor

CreateConfigMap, CreateCronJob, CreateDaemonSet, CreateDeployment, CreateHorizontalPodAutoscaler, CreateHTTPRoute, CreateIngress, CreateJob, CreateNamespace, CreateNetworkPolicy, CreatePersistentVolumeClaim, CreatePodDisruptionBudget, CreateService, CreateServiceAccount and CreateStatefulSet all set:

  • label app: <name>
  • annotation app: <name>

Restore with kubernetes.AddLabel(obj, "app", name) and kubernetes.AddAnnotation(obj, "app", name).

Spec values injected per kind

Every row in the table carries a class, so the consequence survives a reader who samples one row instead of reading this section. The four classes, loudest failure first:

ClassWhat happensHow you find out
rejectedthe API server refuses the manifestat apply time, loudly
silentthe object is valid and means something elsein the cluster, eventually
no-opoutput may change, cluster behaviour does notnothing in the cluster differs
unsettlednot determinable from this repository’s dependenciesstated, not guessed

A rejected manifest is the safe failure: it fails in front of the person applying it. The silent class is the one to read, because nothing anywhere reports it. Every non-no-op row below cites the upstream text that settles it, and a row that could not be settled from source is marked unsettled rather than assumed benign.

The class describes the object you ship, not your build. One row is no-op by that measure and still breaks caller code: CreateConfigMap stopped initialising data and binaryData, so cm.Data[k] = v on a fresh object now assigns into a nil map and panics. That failure is immediate, and in your own process rather than in a cluster — which is why it is not silent — but it is not nothing, and the row says so.

Every file:line below is in the module version it was read at — the version a citation names, or else k8s.io/api v0.37.0, sigs.k8s.io/gateway-api v1.6.3, github.com/cilium/cilium v1.20.1, prometheus-operator monitoring v0.93.1 — not in whichever copy is newest on the machine you read it from. go.mod is the source of truth for what the repository pins now; a later pin does not re-read these citations. Defaulting text moves between versions; a citation against the wrong one is worth less than no citation, because it looks checked.

Rejected by the API server

What “rejected” rests on, so the standard is visible. Each row below is rejected because the type declares the field required and supplies no default. The validating code that performs the refusal lives in k8s.io/kubernetes, which is not a dependency of this repository — the same absence that leaves the PVC storage request and automountServiceAccountToken marked unsettled further down. The difference is what the pinned types settle on their own: a required field with no default is refused on any conformant API server, whereas “is a storage request required” is a validation policy the types do not state. Disclosing this for two rows and not the others would make the undisclosed ones look better established than they are.

spec.selector on CreateDaemonSet, CreateDeployment and CreateStatefulSet is marked +required with no server-side default, and each of the three specs declares the field separately — k8s.io/api@v0.37.0 apps/v1/types.go:697-702 (DaemonSetSpec), :422-426 (DeploymentSpec) and :209-215 (StatefulSetSpec). In all three the field carries json:"selector" with no omitempty, so it is emitted as null rather than omitted. Restore it together with the matching template labels the same constructors stopped setting — the selector must match them:

obj.Spec.Selector = &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}
obj.Spec.Template.Labels = map[string]string{"app": name}

CreateCronJob drops two. spec.schedule is required with no default, and carries no omitempty (batch/v1/types.go:794), so an unset schedule is not absent from the manifest — it is emitted as schedule: "", which is what gets refused. The job pod’s restartPolicy is +optional in the type and defaults to Always (core/v1/types.go:4406), but a Job’s template does not permit that value — “The only allowed template.spec.restartPolicy values are Never or OnFailure” (batch/v1/types.go:405). So leaving it unset is rejected just as surely as omitting the schedule, and this is why +optional on its own settles nothing:

cj.Spec.Schedule = "*/5 * * * *"
cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyNever

Both of these hit only callers who relied on the injected value. If you already assign Spec.Selector and your own template labels — the pattern pkg/kubernetes/README.md documents — you were never depending on the constructor and nothing here applies; do not replace a working custom selector with the generic app: <name> recipe above.

Within that, the two differ in reach. Any caller who left the selector alone lost it, because nothing else wrote it. The CronJob restart policy is narrower still: SetCronJobPodSpec assigned the whole pod spec (cron.Spec.JobTemplate.Spec.Template.Spec = *spec), so a caller who set the pod template that way had already lost the injected Never before this release. Only callers who mutated the template in place still had it.

Accepted, and behaves differently

These produce a valid object. Nothing fails, nothing warns, and the difference only shows up in the cluster.

CreateNetworkPolicy stopped setting spec.podSelector.matchLabels.app: <name>. An empty podSelector is not “no selection” — upstream documents it as “An empty selector matches all pods in the policy’s namespace… If it is not specified, it defaults to an empty selector.” (networking/v1/types.go:62-69; note the field is +optional yet carries json:"podSelector" with no omitempty, so it is always emitted). A policy that applied to one app now applies to every pod in its namespace. Restore the scope explicitly:

np.Spec.PodSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}

CreateStatefulSet stopped setting spec.replicas: 0. The field is a pointer precisely so that an explicit zero is distinguishable from unset, and unset “defaults to 1” (apps/v1/types.go:201-207). A StatefulSet built to start scaled to zero now starts one pod. If zero was deliberate, say so:

sts.Spec.Replicas = ptr.To[int32](0)

CreateIngress stopped setting spec.ingressClassName from its third argument. An Ingress with no class is not unrouted — upstream documents that “when a single IngressClass resource has this annotation set to true, new Ingress resources without a class specified will be assigned this default class.” (networking/v1/types.go:562-566). So the Ingress is silently adopted by whatever controller owns the cluster’s default class, which may not be the one you meant. What happens when no default class exists is not stated by the type and is not claimed here:

ing.Spec.IngressClassName = ptr.To("nginx")

The pod template’s app label is no longer set by CreateCronJob, CreateDaemonSet, CreateDeployment, CreateJob or CreateStatefulSet. No upstream default is involved — the label is simply absent, and anything that selects on it stops matching those pods: a Service, a NetworkPolicy peer, a PodMonitor (whose selector selects Pod objects — podmonitor_types.go:98). A ServiceMonitor selects Endpoints objects (servicemonitor_types.go:103), so it breaks one step removed, through the Service whose own pod selector no longer matches. For the three kinds that also lost spec.selector this is already covered above, because the manifest is rejected before it can matter. For CreateJob it is not: the Job controller generates its own selector, so the object is perfectly valid and only the selection you built around it quietly stops working. CreateCronJob sits between the two. Its controller generates a selector as well, so nothing about the missing label makes the manifest invalid — but the manifest is refused anyway, for the schedule and restart policy above. The label consequence is real and starts applying the moment those two are restored, which is why the row names it behind the rejection rather than instead of it.

job.Spec.Template.Labels = map[string]string{"app": name}

The object’s own app label and annotation are gone too, and this one is not confined to the pod-template kinds: every one of the fifteen core constructors set both metadata.labels.app: <name> and metadata.annotations.app: <name> on the object it returned. For CreateHorizontalPodAutoscaler and CreatePodDisruptionBudget it was the only injected default, which is why those two appear in the table with nothing else against them. The four CRD constructors — CreateServiceMonitor, CreatePodMonitor, CreatePrometheusRule, cilium.CreateCiliumCIDRGroup — never did it, and neither did the RBAC constructors, which were already identity-only. Restore it where you relied on it:

kubernetes.AddLabel(obj, "app", name)
kubernetes.AddAnnotation(obj, "app", name)

The consequence is the same shape as the pod-template label — anything selecting the object by app: <name> stops matching it — but it does not cost the same on every kind, and the difference is not a judgement call. Some labels sit on a surface the API itself defines a selector for that kind, and some do not. Pod template labels do: a Service routes “traffic to pods with label keys and values matching this selector” (core/v1/types.go:6275-6276), a NetworkPolicy podSelector selects pods in the policy’s own namespace (networking/v1/types.go:62-64) and a PodMonitor selects pods to scrape (podmonitor_types.go:98) — which is why losing them is silent rather than cosmetic. Namespace labels do too — “namespaceSelector selects namespaces using cluster-scoped labels” (networking/v1/types.go:209-210) — so a Namespace that quietly dropped app: <name> falls out of every NetworkPolicy peer whose namespaceSelector requires that label, and CreateNamespace is silent for that reason alone. Without it that row would be no-op. It does not fall out of a peer that selected it some other way: an empty namespaceSelector “selects all namespaces” (networking/v1/types.go:210) and still matches.

A Service’s own labels sit on such a surface as well, through a CRD rather than through core: Cilium’s network policy carries a “K8sServiceSelector selects services by k8s labels and namespace” (cilium@v1.20.1 pkg/policy/api/service.go:14), and a CiliumLoadBalancerIPPool’s serviceSelector decides which Services may draw an IP from the pool (pkg/k8s/apis/cilium.io/v2/lbipam_types.go:69-72). Both are in the module this repository already depends on, the same standard the CIDR-group row below relies on. So CreateService is silent on the same standard as the rest, and the Endpoints route that looks like the way to settle it is not needed: a ServiceMonitor selects Endpoints objects and “in most cases, an Endpoints object is backed by a … Service object with the same name and labels” (servicemonitor_types.go:99,103), but that label copying is done by a controller in k8s.io/kubernetes, which is not a dependency here, and the sentence hedges. That path stays unsettleable; it simply no longer carries the question.

No pinned type defines a selector for a ConfigMap, an HPA, an HTTPRoute or a PodDisruptionBudget — one whose own definition names the kind it selects, the way Service.spec.selector names pods and namespaceSelector names namespaces. Gateway API comes closest and does not qualify: both of its label selectors pick namespaces, not routes — “only Routes in Namespaces matching this Selector will be selected by this Gateway” (gateway-api@v1.6.3 apis/v1/gateway_types.go:892-893). So those four rows stay no-op and simply name this removal: for CreateConfigMap and CreateHTTPRoute that is the class their other removals already give them, and for CreateHorizontalPodAutoscaler and CreatePodDisruptionBudget — where the label and annotation were the only injected default — it is the class on its own.

Two mechanisms in the pinned dependencies do match objects by their own labels regardless of kind, which is why the line above is drawn at kind-specific selection rather than at label matching in general. An admission webhook’s objectSelector “decides whether to run the webhook based on if the object has matching labels” (admissionregistration/v1/types.go:1032-1033), and a Flux Kustomization’s inline patches are “defined as inline YAML objects, capable of targeting objects based on kind, label and annotation selectors” (kustomize-controller/api@v1.9.4 v1/kustomization_types.go:129-130). Both apply to every row in the table, including the ones already silent, so neither moves one row’s class relative to another’s. What they do change is the floor, and it is the one thing here worth checking before you upgrade even on a no-op row: if a webhook or a Flux patch in your cluster targets app: <name>, the removal is silent for you on whichever kind it targets. The same goes for a selector you wrote yourself — kubectl -l, a Kustomize label selector, your own controller. The class states what these types guarantee; it cannot state what your cluster is configured to do.

Renders null where it used to render []

Three CRD list fields carry no omitempty, so they are always emitted: an empty slice rendered [], and nil renders null. They do not share a consequence, and that is the whole reason for checking each rather than classing them together:

  • cilium.CreateCiliumCIDRGroup, spec.externalCIDRs — rejected. Cilium ships its CRD manifests inside the Go module this repository already depends on, so this one is settleable here after all: cilium@v1.20.1 pkg/k8s/apis/cilium.io/client/crds/v2/ciliumcidrgroups.yaml:48-60 declares type: array with minItems: 0 and required: [externalCIDRs], and gives it neither nullable nor a default. A null value for a non-nullable, non-defaultable field is pruned before validation runs — apiextensions-apiserver, PruneNonNullableNullsWithoutDefaults — which leaves a required field absent, so the object is refused. [] was accepted; null is not.
  • prometheus.CreateServiceMonitor, spec.endpoints — unsettled. The field is +required (servicemonitor_types.go:100), so the same pruning would reject it — but prometheus-operator does not ship its CRDs inside the Go module, so the nullable flag in the schema that actually runs cannot be read from here. Expect the Cilium outcome; the CRD installed in your cluster is what settles it.
  • prometheus.CreatePodMonitor, spec.podMetricsEndpoints — no-op. Despite the missing omitempty this field is +optional (podmonitor_types.go:95), so a pruned null leaves an absent optional field and nothing is refused. The emitted YAML changes; nothing else does.

omitempty and +required are separate markers. The first decides whether the field is emitted, the second whether it may be missing — and reading one for the other is exactly how the third row above would have been filed with the first. If you build these kinds with no entries, set the empty slice explicitly:

sm.Spec.Endpoints = []monitoringv1.Endpoint{}

The rows that cannot be settled from this repository

Exactly three rows are marked unsettled in the table. “Unsettled” is a claim about this repository’s dependencies, so each one has to survive the same test the CiliumCIDRGroup row passes above: a pinned module that ships its own schema does settle a question, and finding nothing is only evidence if you looked where the answer would be. Each of the three was checked that way.

  • prometheus.CreateServiceMonitor, spec.endpoints — the CRD schema that would decide it is genuinely absent, not merely uncited: the pinned prometheus-operator/pkg/apis/monitoring@v0.93.1 module contains 41 files and not one YAML or JSON among them. It ships Go types only. That is the difference from cilium@v1.20.1, which ships its CRD manifests inside the module and therefore settles its own row.
  • CreatePersistentVolumeClaim, spec.resources.requests.storage: 1Gi — whether a storage request is required lives in k8s.io/kubernetes validation, not a dependency of this repository. The type is not merely silent, it is inapplicable: the doc comment on VolumeResourceRequirements.Requests still describes what happens when requests are “omitted for a container” (core/v1/types.go:3087-3089), inherited text from the era the same struct served both — the field below it records that volumes shared ResourceRequirements with containers “by accident” (:3094-3096). A comment written about containers cannot settle a claim about volumes.
  • CreateServiceAccount, automountServiceAccountToken: false — the type documents no default, only that it “indicates whether pods running as this service account should have an API token automatically mounted” and “can be overridden at the pod level” (core/v1/types.go:6664-6667); the behaviour is applied by the ServiceAccount admission controller. The table still names SetServiceAccountAutomountToken(sa, false), which is what a caller who relied on the injected false needs regardless of what the cluster would do.

The distinction that matters for both: knowing where a default comes from is not knowing what it is. Neither is claimed here in either direction.

Where a constructor dropped several values with different consequences, the class is the one you hit first, not the one the preface calls most dangerous: a rejected manifest never reaches the cluster, so a silent consequence sitting behind it cannot happen until the rejection is fixed. Those subordinate consequences are still named in the row, because fixing the rejection is exactly when they start to apply.

ConstructorClassRemoved default
CreateConfigMapno-opdata and binaryData initialised to empty maps. Both carry omitempty, so these two initialisations produce no manifest difference — but a fresh object accepted cm.Data[k] = v directly and both are now nil, so that assignment panics. Write through AddConfigMapData/AddConfigMapBinaryData (which nil-init), or assign a map literal first (cm.Data = map[string]string{...}); the object’s own app label and annotation
CreateCronJobrejectedspec.schedule from the third argument (required, see above); spec.jobTemplate.spec.template.spec.restartPolicy: Never (required, see above); spec.jobTemplate.spec.template.metadata.labels.app: <name> (silent — the Job controller supplies its own selector, so only external selection breaks); the object’s own app label and annotation
CreateDaemonSetrejectedspec.selector.matchLabels.app: <name> (required, see above); spec.template.metadata.labels.app: <name>; the object’s own app label and annotation
CreateDeploymentrejectedspec.selector.matchLabels.app: <name> (required, see above); spec.template.metadata.labels.app: <name>; the object’s own app label and annotation
CreateHorizontalPodAutoscalerno-opthe object’s own app label and annotation — its only injected default
CreateHTTPRouteno-opempty spec.hostnames and spec.rules slices — both omitempty, so no output change; the object’s own app label and annotation
CreateIngresssilentspec.ingressClassName from the third argument — the Ingress is adopted by the cluster’s default IngressClass, see above; empty spec.rules and spec.tls slices (omitempty, no output change); the object’s own app label and annotation
CreateJobsilentspec.template.metadata.labels.app: <name> — the object stays valid, but anything selecting app: <name> stops matching its pods, see above; the object’s own app label and annotation
CreateNamespacesilentthe object’s own app label and annotation — a Namespace that dropped app: <name> falls out of every NetworkPolicy peer whose namespaceSelector required that label, see above; empty spec.finalizers slice (omitempty, no output change)
CreateNetworkPolicysilentspec.podSelector.matchLabels.app: <name> (rescopes the policy to the whole namespace, see above); empty spec.policyTypes, spec.ingress and spec.egress slices (all omitempty, no output change — unlike podSelector, which has none); the object’s own app label and annotation
CreatePersistentVolumeClaimunsettledspec.resources.requests.storage: 1Gi — unsettled, see above; spec.volumeMode: Filesystem (no-op — upstream: “Value of Filesystem is implied when not included in claim spec”); empty spec.accessModes slice (no-op — an empty list satisfied no requirement either); the object’s own app label and annotation
CreatePodDisruptionBudgetno-opthe object’s own app label and annotation — its only injected default
CreateServicesilentthe object’s own app label and annotation — a Service that dropped app: <name> falls out of any Cilium K8sServiceSelector or LoadBalancer IP-pool serviceSelector that required it, see above; empty spec.selector map and spec.ports slice (both omitempty, and an empty selector meant “no selector” exactly as nil does)
CreateServiceAccountunsettledautomountServiceAccountToken: false (a pointer to false, serialised) — unsettled, see above. The field is now unset and the effective value comes from the cluster, so if you relied on the injected false, restore it with SetServiceAccountAutomountToken(sa, false); empty secrets and imagePullSecrets slices (omitempty, no output change); the object’s own app label and annotation
CreateStatefulSetrejectedspec.selector.matchLabels.app: <name> (required, see above); spec.replicas: 0 (a pointer to zero, serialised — unset now defaults to 1, silent, see above); spec.template.metadata.labels.app: <name>; spec.podManagementPolicy: OrderedReady (no-op — upstream: “The default policy is OrderedReady”); empty spec.volumeClaimTemplates slice (omitempty, no output change); the object’s own app label and annotation
prometheus.CreateServiceMonitorunsettledempty spec.endpoints slice. +required with no omitempty, so this now renders null instead of []; whether that is refused depends on a CRD schema not shipped here — see above (at this release also observable through prometheus.ServiceMonitor(cfg) with no endpoints; release 2 retired it)
prometheus.CreatePodMonitorno-opempty spec.podMetricsEndpoints slice. No omitempty, so this now renders null instead of [] — but the field is +optional, so a pruned null is simply an absent optional field, see above (at this release also observable through prometheus.PodMonitor(cfg) with no endpoints; release 2 retired it)
prometheus.CreatePrometheusRuleno-opempty spec.groups slice (prometheus.PrometheusRule(cfg), retired in release 2, left it nil too, but spec.groups carries omitempty, so the emitted YAML does not change)
cilium.CreateCiliumCIDRGrouprejectedempty spec.externalCIDRs slice. Required with minItems: 0 and no omitempty, so this now renders null instead of [] — and Cilium’s own bundled CRD makes the field non-nullable, so the null is pruned and the required field is then missing, see above (at this release also observable through cilium.CiliumCIDRGroup(cfg) with no CIDRs; release 2 retired it)

What a golden file of the old output actually shows

If you kept manifests rendered before this release and diff them against manifests rendered after, you get exactly three kinds of line. Knowing which is which is most of the migration:

  • Values that vanish. The leaf line is simply gone, because the field carries omitempty and is now empty: replicas: 0 (look at this one twice — the omitempty sits on a pointer, apps/v1/types.go:207, so an explicit zero was emitted and is now omitted entirely), volumeMode: Filesystem, podManagementPolicy: OrderedReady, ingressClassName, restartPolicy: Never, automountServiceAccountToken: false, and the object’s own app label and annotation.
  • Values that become null, {} or "". The field is emitted whatever it holds, so the line stays and the value degrades. Two different things put a field here — no omitempty at all, or a struct-typed field, for which omitempty does nothing (see below): spec.selector on DaemonSet/Deployment/StatefulSet renders null (apps/v1/types.go:702, :426 and :215 respectively — one declaration per spec), NetworkPolicy’s spec.podSelector renders {} (networking/v1/types.go:69 — a value type, so the zero struct, not null), CronJob’s spec.schedule renders "" (batch/v1/types.go:794), the three CRD list fields render null where they rendered [], the PVC’s spec.resources renders {} (core/v1/types.go:566), the Ingress’s whole spec renders {} (networking/v1/types.go:261) now that the class name was its only content, and the pod template’s metadata renders {} on all five pod-template kinds (core/v1/types.go:5834) now that the app label was its only content.
  • Nothing at all — for every other collection in the table. They carry omitempty, so nil and []/{} serialise identically: the diff shows no line for them, not a shorter one.

null, {} and "" are the only degraded values in this table, not a general law: a field emitted regardless of content that happened to be numeric or boolean degrades to 0 or false instead. No removed default is one — but the shape is live in this repository rather than hypothetical. spec.maxReplicas on a HorizontalPodAutoscaler carries no omitempty (autoscaling/v2/types.go:73), which is why pkg/kubernetes/testdata/hpa-scale-target-ref.yaml renders maxReplicas: 0. It renders that on both sides of this release, so it is not a diff line; it is what you would be looking at if one of these constructors had injected a number.

Why a field with omitempty can still be emitted. omitempty drops a field only when its value is empty, and Go defines that as “false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero” (encoding/json/encode.go:107-110, Go 1.26.8). A struct is not on that list, so a struct-typed field is written out however empty it is — which is why json:"resources,omitempty", json:"spec,omitempty" and json:"metadata,omitempty" all still produce a {}. Seven of the nineteen rows have a line of this kind, and reading the tag alone predicts the wrong group for every one of them. The repository’s own fixture shows it already: pkg/kubernetes/testdata/deployment-with-init-container.yaml carries strategy: {}, metadata: {} and resources: {}.

Which group a line lands in tells you where to look, not what it costs. The mapping is tempting in both directions and wrong in both: restartPolicy: Never vanishes and its absence is rejected, while podMetricsEndpoints: null degrades and is no-op. Use the diff to find the changed objects, then read the class in the table for each one.

The other hand-written constructors (cert-manager, CloudNativePG, External Secrets, Flux, MetalLB, VolSync, the remaining Cilium kinds, RBAC) were already identity-only; their removal changes no output.

Scheme additions

autoscaling/v2, policy/v1 and the Barman Cloud plugin API (barmancloud.cnpg.io/v1) are now registered in kubernetes.Scheme. kure built these kinds before but could not resolve their GVK; GetGroupVersionKind now succeeds for them.

Bare field forwarders removed by the prune PR

A helper that does nothing but assign one argument to one struct field is not sugar under the contract — the assignment is already the shortest way to write it. All 257 such forwarders are removed. The tables below give the replacement expression for each; the field is exactly the one the helper wrote, so the rewrite is mechanical and never changes behaviour.

At the start of this work item pkg/kubernetes/testdata/admission_exclusions.txt held 344 tolerated helpers, classified by why the admission test rejected them: 257 bare field forwarders, 51 that return a value, 24 that write no field at all (they delegate, or are no-ops), 10 that return early instead of writing, and 2 that clear a sibling field the caller did not name. The 257 go first; folding the per-kind pod-template passthroughs (below) takes the remaining 87 down to 33, and the later work items empty the file. Six of the 24 delegators now read as bare field assignments rather than delegations, because the helper they delegated to was one of the 257 and their bodies were inlined: SetDaemonSetServiceAccountName, SetDaemonSetNodeSelector, SetJobServiceAccountName, SetJobNodeSelector, SetStatefulSetServiceAccountName and SetStatefulSetNodeSelector. They are folded onto the PodSpec helpers below.

Sub-type constructors (9)

A sub-type that is not a client.Object gets no generated constructor: a struct literal is both shorter and the only form that shows every field being set. Read that as a statement about generation, not a blanket removal. The nine rows below are the sub-type constructors this release removed; hand-written sub-type helpers that were not on that list survive this release unchanged and their call sites still compile. pkg/kubernetes/README.md:60-78 states the split and names the survivors: CreateIngressPath at pkg/kubernetes/ingress.go:9 and CreateResourceRequirements at pkg/kubernetes/resourcerequirements.go:10 in this package — both under review for the next work item, per that page — plus the ones the kind sub-packages still export, such as CreatePostRendererKustomize at pkg/kubernetes/fluxcd/setters.go:465. So a consumer rewrites the nine rows below, and nothing else on account of this release.

Each row gives the removed function’s exact former signature and a literal that produces the same value. Where the constructor injected defaults, they are listed with it — a caller that relied on one now writes it, and a caller that did not gets a smaller object.

RemovedReplacement
CreateContainer(name string, image string, command []string, args []string) *corev1.Containersee below — it injected seven fields
CreatePodSpec() *corev1.PodSpecsee below — it injected eleven
CreateVolumeClaimTemplate(name string, opts VolumeClaimTemplateOptions) corev1.PersistentVolumeClaimsee below
CreateIngressRule(host string) *netv1.IngressRulesee below
CreateACMEIssuer(server, email string, key cmmeta.SecretKeySelector) *cmacme.ACMEIssuersee below
CreateACMEHTTP01Solver(serviceType corev1.ServiceType, class string) cmacme.ACMEChallengeSolversee below
CreateACMEDNS01SolverCloudflare(email string, token cmmeta.SecretKeySelector) cmacme.ACMEChallengeSolversee below
CreateACMEDNS01SolverRoute53(region string, key cmmeta.SecretKeySelector) cmacme.ACMEChallengeSolversee below
CreateACMEDNS01SolverGoogle(project string, sa *cmmeta.SecretKeySelector) cmacme.ACMEChallengeSolversee below

CreateContainer set seven fields beyond its four arguments: five empty collections, Resources and ImagePullPolicy. Counting the individual values instead of the fields gives nine — a memory limit, a CPU request, a memory request, the pull policy, and the five collections. The table’s number is fields. A caller that took those silently now gets a container that states no requests and no limits of its own. Whether that leaves it with none is a cluster question, not a type question: with no limits specified, an omitted request defaults to “an implementation-defined value” (core/v1/types.go:3060-3062), and a LimitRange in the namespace supplies a default for an omitted limit and for an omitted request alike (core/v1/types.go:8052-8057). What the removal guarantees is that the manifest no longer carries the old numbers — not that nothing takes their place. The behaviour-preserving replacement:

&corev1.Container{
    Name:    name,
    Image:   image,
    Command: command,
    Args:    args,
    Resources: corev1.ResourceRequirements{
        Limits:   corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("256Mi")},
        Requests: corev1.ResourceList{
            corev1.ResourceCPU:    resource.MustParse("100m"),
            corev1.ResourceMemory: resource.MustParse("256Mi"),
        },
    },
    ImagePullPolicy: corev1.PullIfNotPresent,
}

The five empty collections it also set (Ports, Env, EnvFrom, VolumeMounts, VolumeDevices) never rendered and need no replacement: append works on a nil slice. Drop the Resources and ImagePullPolicy keys if the defaults were not wanted — that is the point of removing them.

In the table’s own terms these are silent, and the table does not say so because it classifies kinds and a Container is not one. The scheme still applies, so it is worth stating rather than leaving to a reader who only read the table: a container that lost its memory limit and its CPU and memory requests is a valid container that asks for nothing explicitly and states no cap of its own — the manifest is accepted and no controller reports the change. What it ends up scheduled and capped with is then whatever admission supplies (LimitRange defaults, core/v1/types.go:8052-8057) or, absent that, the implementation-defined request the type documents (core/v1/types.go:3060-3062). silent is the right class on either path, and for the same reason: nothing in the rendered manifest and nothing in any controller’s status tells the caller the numbers changed.

The pull policy is different in kind, and worth stating carefully, because its class is a property of the caller’s input rather than of the default itself — it cannot be classified from the removed value alone. It is not the only row of that shape: CreateIngress above is another, for the same reason. It too stopped assigning a value its caller supplied, against an upstream default that may or may not coincide with what was supplied, so that removal likewise preserves or changes routing depending on the caller’s input rather than on the removed value. Here the type documents “Defaults to Always if :latest tag is specified, or IfNotPresent otherwise” (core/v1/types.go:3296), so for an image carrying an explicit tag other than :latest the effective value is IfNotPresent either way and the removal is no-op, while for an image on :latest it becomes Always and the container is re-pulled on every start where it previously was not — silent.

Two limits on that, both of which this document’s own standard requires stating. The evidence is the pinned type’s documented default, the same kind of evidence the podManagementPolicy and volumeMode rows rest on — but the defaulting itself is implemented in k8s.io/kubernetes, which is in neither go.mod nor go.sum here, so what can be read is a comment about the behaviour and not the behaviour. And the comment’s otherwise is doing more work than it can be checked for: an image written with no tag at all is not settled by that sentence, and which branch it takes cannot be determined from this repository. If your images are untagged, treat this removal as unsettled and pin the policy explicitly rather than inferring it from the sentence above.

CreatePodSpec set RestartPolicy: Always, a zero TerminationGracePeriodSeconds pointer, an empty SecurityContext, an empty Affinity, an empty NodeSelector map, an empty ServiceAccountName string (the zero value, so writing it changed nothing) and five empty slices — eleven fields, the number in the table above. Always is the API server’s own default for a Pod. Four of the five slices (InitContainers, Volumes, ImagePullSecrets, Tolerations) carry omitempty upstream and so never rendered, but Containers does not: PodSpec.Containers is required with no omitempty (k8s.io/api/core/v1/types.go:4393), so an empty slice serialises as containers: [] where nil serialises as containers: null. To keep the old value (all but restartPolicy: Always, which the old constructor also wrote and which is the server-side default, so the Pod behaves the same):

&corev1.PodSpec{
    Containers:                    []corev1.Container{},
    TerminationGracePeriodSeconds: new(int64),
    SecurityContext:               &corev1.PodSecurityContext{},
    Affinity:                      &corev1.Affinity{},
    NodeSelector:                  map[string]string{},
}

A caller that wants none of them writes &corev1.PodSpec{}. The Containers difference disappears as soon as one container is appended, so it matters only for a spec serialised while still empty.

TerminationGracePeriodSeconds needs its own note: its removal changes what the cluster does, and in the table’s terms it is silent — the same shape as spec.replicas: 0 above. The pointer was to zero, and omitempty on a pointer elides only nil, so the old output carried terminationGracePeriodSeconds: 0 and the new output omits the line. Upstream documents that “the value zero indicates stop immediately via the kill signal (no opportunity to shut down)”, that “if this value is nil, the default grace period will be used instead”, and that it “defaults to 30 seconds” (core/v1/types.go:4410-4419). So a pod that was killed the instant it was deleted now gets 30 seconds to shut down instead. The object stays valid, nothing refuses it and no controller reports it — you find out from how long deletes take. Keeping new(int64), as the literal above does, restores immediate termination.

CreateVolumeClaimTemplate took an options struct, also removed (VolumeClaimTemplateOptions), and returned a value rather than a pointer. It left StorageClassName nil when the option was empty, which is how the API selects the cluster default — the literal must keep that condition:

pvc := corev1.PersistentVolumeClaim{
    ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels},
    Spec: corev1.PersistentVolumeClaimSpec{
        AccessModes: accessModes,
        Resources: corev1.VolumeResourceRequirements{
            Requests: corev1.ResourceList{corev1.ResourceStorage: storageRequest},
        },
    },
}
if storageClass != "" {
    pvc.Spec.StorageClassName = &storageClass
}

CreateIngressRule also built the nested HTTP value, with Paths initialised to an empty slice, so a bare &netv1.IngressRule{Host: host} differs in three ways: it serialises without the http: {paths: []} value; a caller that writes rule.IngressRuleValue.HTTP.Paths directly dereferences a nil pointer; and HTTPIngressRuleValue.Paths is required with no omitempty (k8s.io/api/networking/v1/types.go:452), so the old value serialises paths: [] where a nil slice serialises paths: null. Going through AddIngressRulePath is safe either way — it nil-initialises HTTP before appending — so most callers need no change. To keep the old value exactly:

&netv1.IngressRule{
    Host: host,
    IngressRuleValue: netv1.IngressRuleValue{
        HTTP: &netv1.HTTPIngressRuleValue{
            Paths: []netv1.HTTPIngressPath{},
        },
    },
}

As with PodSpec.Containers, the paths difference disappears once a path is appended.

The cert-manager five:

// CreateACMEIssuer(server, email, key)
&cmacme.ACMEIssuer{Server: server, Email: email, PrivateKey: key}

// CreateACMEHTTP01Solver(serviceType, class) -- class == "" left the pointer nil
solver := cmacme.ACMEChallengeSolver{
    HTTP01: &cmacme.ACMEChallengeSolverHTTP01{
        Ingress: &cmacme.ACMEChallengeSolverHTTP01Ingress{ServiceType: serviceType},
    },
}
if class != "" {
    solver.HTTP01.Ingress.IngressClassName = &class
}

// CreateACMEDNS01SolverCloudflare(email, token)
cmacme.ACMEChallengeSolver{DNS01: &cmacme.ACMEChallengeSolverDNS01{
    Cloudflare: &cmacme.ACMEIssuerDNS01ProviderCloudflare{Email: email, APIToken: &token},
}}

// CreateACMEDNS01SolverRoute53(region, key)
cmacme.ACMEChallengeSolver{DNS01: &cmacme.ACMEChallengeSolverDNS01{
    Route53: &cmacme.ACMEIssuerDNS01ProviderRoute53{Region: region, SecretAccessKey: key},
}}

// CreateACMEDNS01SolverGoogle(project, sa)
cmacme.ACMEChallengeSolver{DNS01: &cmacme.ACMEChallengeSolverDNS01{
    CloudDNS: &cmacme.ACMEIssuerDNS01ProviderCloudDNS{Project: project, ServiceAccount: sa},
}}

CreateACMEIssuer also set Solvers to an empty slice and both boolean fields to false, none of which changes what is serialised or what append does.

The four cert-manager solver constructors and CreateACMEIssuer were only ever called from certmanager.Issuer/ClusterIssuer, which from this release built the literals inline (release 2 retired both).

VolumeClaimTemplateOptions goes with CreateVolumeClaimTemplate, its only consumer. Its four fields — StorageClassName, AccessModes, StorageRequest and Labels — appear in the literal above, where the API names them.

pkg/kubernetes (76)

RemovedReplacement
SetClusterRoleBindingRoleRef(crb, roleRef)crb.RoleRef = roleRef
SetContainerArgs(container, args)container.Args = args
SetContainerCommand(container, command)container.Command = command
SetContainerImage(container, image)container.Image = image
SetContainerImagePullPolicy(container, imagePullPolicy)container.ImagePullPolicy = imagePullPolicy
SetContainerResources(container, resources)container.Resources = resources
SetContainerStdin(container, stdin)container.Stdin = stdin
SetContainerStdinOnce(container, once)container.StdinOnce = once
SetContainerTTY(container, tty)container.TTY = tty
SetContainerTerminationMessagePath(container, path)container.TerminationMessagePath = path
SetContainerTerminationMessagePolicy(container, policy)container.TerminationMessagePolicy = policy
SetContainerWorkingDir(container, dir)container.WorkingDir = dir
SetCronJobConcurrencyPolicy(cron, policy)cron.Spec.ConcurrencyPolicy = policy
SetCronJobNodeSelector(cron, nodeSelector)cron.Spec.JobTemplate.Spec.Template.Spec.NodeSelector = nodeSelector
SetCronJobSchedule(cron, schedule)cron.Spec.Schedule = schedule
SetCronJobServiceAccountName(cron, serviceAccountName)cron.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName = serviceAccountName
SetDaemonSetUpdateStrategy(ds, strategy)ds.Spec.UpdateStrategy = strategy
SetDeploymentMinReadySeconds(deployment, secs)deployment.Spec.MinReadySeconds = secs
SetDeploymentNodeSelector(deployment, nodeSelector)deployment.Spec.Template.Spec.NodeSelector = nodeSelector
SetDeploymentServiceAccountName(deployment, serviceAccountName)deployment.Spec.Template.Spec.ServiceAccountName = serviceAccountName
SetDeploymentStrategy(deployment, strategy)deployment.Spec.Strategy = strategy
SetHPAAnnotations(hpa, annotations)hpa.Annotations = annotations
SetHPALabels(hpa, labels)hpa.Labels = labels
SetHTTPRouteHostnames(route, hostnames)route.Spec.Hostnames = hostnames
SetHTTPRouteParentRefs(route, refs)route.Spec.ParentRefs = refs
SetHTTPRouteRuleBackendRefs(rule, refs)rule.BackendRefs = refs
SetHTTPRouteRuleFilters(rule, filters)rule.Filters = filters
SetHTTPRouteRuleMatches(rule, matches)rule.Matches = matches
SetHTTPRouteRules(route, rules)route.Spec.Rules = rules
SetNamespaceAnnotations(ns, annotations)ns.Annotations = annotations
SetNamespaceFinalizers(ns, finalizers)ns.Spec.Finalizers = finalizers
SetNamespaceLabels(ns, labels)ns.Labels = labels
SetNetworkPolicyEgressPeers(rule, peers)rule.To = peers
SetNetworkPolicyEgressPorts(rule, ports)rule.Ports = ports
SetNetworkPolicyEgressRules(np, rules)np.Spec.Egress = rules
SetNetworkPolicyIngressPeers(rule, peers)rule.From = peers
SetNetworkPolicyIngressPorts(rule, ports)rule.Ports = ports
SetNetworkPolicyIngressRules(np, rules)np.Spec.Ingress = rules
SetNetworkPolicyPodSelector(np, selector)np.Spec.PodSelector = selector
SetNetworkPolicyPolicyTypes(np, types)np.Spec.PolicyTypes = types
SetPDBAnnotations(pdb, annotations)pdb.Annotations = annotations
SetPDBLabels(pdb, labels)pdb.Labels = labels
SetPVCResources(pvc, resources)pvc.Spec.Resources = resources
SetPVCVolumeName(pvc, volumeName)pvc.Spec.VolumeName = volumeName
SetPodSpecDNSPolicy(spec, policy)spec.DNSPolicy = policy
SetPodSpecHostIPC(spec, hostIPC)spec.HostIPC = hostIPC
SetPodSpecHostNetwork(spec, hostNetwork)spec.HostNetwork = hostNetwork
SetPodSpecHostPID(spec, hostPID)spec.HostPID = hostPID
SetPodSpecHostname(spec, hostname)spec.Hostname = hostname
SetPodSpecNodeSelector(spec, selector)spec.NodeSelector = selector
SetPodSpecPriorityClassName(spec, class)spec.PriorityClassName = class
SetPodSpecRestartPolicy(spec, policy)spec.RestartPolicy = policy
SetPodSpecSchedulerName(spec, scheduler)spec.SchedulerName = scheduler
SetPodSpecServiceAccountName(spec, name)spec.ServiceAccountName = name
SetPodSpecSubdomain(spec, subdomain)spec.Subdomain = subdomain
SetRoleBindingRoleRef(rb, roleRef)rb.RoleRef = roleRef
SetServiceAccountAnnotations(sa, annotations)sa.Annotations = annotations
SetServiceAccountImagePullSecrets(sa, secrets)sa.ImagePullSecrets = secrets
SetServiceAccountLabels(sa, labels)sa.Labels = labels
SetServiceAccountSecrets(sa, secrets)sa.Secrets = secrets
SetServiceAnnotations(svc, anns)svc.Annotations = anns
SetServiceClusterIP(service, ip)service.Spec.ClusterIP = ip
SetServiceExternalName(svc, name)svc.Spec.ExternalName = name
SetServiceExternalTrafficPolicy(service, trafficPolicy)service.Spec.ExternalTrafficPolicy = trafficPolicy
SetServiceHealthCheckNodePort(svc, port)svc.Spec.HealthCheckNodePort = port
SetServiceIPFamilies(svc, fams)svc.Spec.IPFamilies = fams
SetServiceLabels(svc, labels)svc.Labels = labels
SetServiceLoadBalancerSourceRanges(svc, ranges)svc.Spec.LoadBalancerSourceRanges = ranges
SetServicePublishNotReadyAddresses(svc, publish)svc.Spec.PublishNotReadyAddresses = publish
SetServiceSelector(service, selector)service.Spec.Selector = selector
SetServiceSessionAffinity(service, affinity)service.Spec.SessionAffinity = affinity
SetServiceType(service, type_)service.Spec.Type = type_
SetStatefulSetMinReadySeconds(sts, secs)sts.Spec.MinReadySeconds = secs
SetStatefulSetPodManagementPolicy(sts, policy)sts.Spec.PodManagementPolicy = policy
SetStatefulSetServiceName(sts, svc)sts.Spec.ServiceName = svc
SetStatefulSetUpdateStrategy(sts, strategy)sts.Spec.UpdateStrategy = strategy

pkg/kubernetes/certmanager (4)

RemovedReplacement
SetCertificateIssuerRef(obj, ref)obj.Spec.IssuerRef = ref
SetCertificateSpec(obj, spec)obj.Spec = spec
SetClusterIssuerSpec(obj, spec)obj.Spec = spec
SetIssuerSpec(obj, spec)obj.Spec = spec

pkg/kubernetes/cilium (19)

RemovedReplacement
SetCiliumBGPAdvertisementSpec(obj, spec)obj.Spec = spec
SetCiliumBGPClusterConfigSpec(obj, spec)obj.Spec = spec
SetCiliumBGPNodeConfigOverrideSpec(obj, spec)obj.Spec = spec
SetCiliumBGPNodeConfigSpec(obj, spec)obj.Spec = spec
SetCiliumBGPPeerConfigSpec(obj, spec)obj.Spec = spec
SetCiliumCIDRGroupCIDRs(obj, cidrs)obj.Spec.ExternalCIDRs = cidrs
SetCiliumClusterwideEnvoyConfigSpec(obj, spec)obj.Spec = spec
SetCiliumClusterwideNetworkPolicySpecs(obj, specs)obj.Specs = specs
SetCiliumEgressGatewayPolicySpec(obj, spec)obj.Spec = spec
SetCiliumEnvoyConfigSpec(obj, spec)obj.Spec = spec
SetCiliumLoadBalancerIPPoolAllowFirstLastIPs(obj, allow)obj.Spec.AllowFirstLastIPs = allow
SetCiliumLoadBalancerIPPoolDisabled(obj, disabled)obj.Spec.Disabled = disabled
SetCiliumLoadBalancerIPPoolSpec(obj, spec)obj.Spec = spec
SetCiliumLocalRedirectPolicyBackend(obj, backend)obj.Spec.RedirectBackend = backend
SetCiliumLocalRedirectPolicyDescription(obj, desc)obj.Spec.Description = desc
SetCiliumLocalRedirectPolicyFrontend(obj, frontend)obj.Spec.RedirectFrontend = frontend
SetCiliumLocalRedirectPolicySkipRedirectFromBackend(obj, skip)obj.Spec.SkipRedirectFromBackend = skip
SetCiliumLocalRedirectPolicySpec(obj, spec)obj.Spec = spec
SetCiliumNetworkPolicySpecs(obj, specs)obj.Specs = specs

pkg/kubernetes/cnpg (9)

RemovedReplacement
SetDatabaseClusterRef(obj, clusterName)obj.Spec.ClusterRef = corev1.LocalObjectReference{Name: clusterName}
SetDatabaseEnsure(obj, ensure)obj.Spec.Ensure = ensure
SetDatabaseOwner(obj, owner)obj.Spec.Owner = owner
SetDatabaseReclaimPolicy(obj, policy)obj.Spec.ReclaimPolicy = policy
SetObjectStoreDestinationPath(obj, path)obj.Spec.Configuration.DestinationPath = path
SetObjectStoreEndpointURL(obj, url)obj.Spec.Configuration.EndpointURL = url
SetObjectStoreRetentionPolicy(obj, policy)obj.Spec.RetentionPolicy = policy
SetScheduledBackupBackupOwnerReference(obj, ref)obj.Spec.BackupOwnerReference = ref
SetScheduledBackupMethod(obj, method)obj.Spec.Method = method

pkg/kubernetes/externalsecrets (7)

RemovedReplacement
SetClusterSecretStoreController(obj, controller)obj.Spec.Controller = controller
SetClusterSecretStoreSpec(obj, spec)obj.Spec = spec
SetExternalSecretSecretStoreRef(obj, ref)obj.Spec.SecretStoreRef = ref
SetExternalSecretSpec(obj, spec)obj.Spec = spec
SetSecretStoreController(obj, controller)obj.Spec.Controller = controller
SetSecretStoreSpec(obj, spec)obj.Spec = spec
SetTarget(obj, target)obj.Spec.Target = target

pkg/kubernetes/fluxcd (118)

RemovedReplacement
SetAlertEventSeverity(alert, sev)alert.Spec.EventSeverity = sev
SetAlertProviderRef(alert, ref)alert.Spec.ProviderRef = ref
SetAlertSpec(obj, spec)obj.Spec = spec
SetAlertSummary(alert, summary)alert.Spec.Summary = summary
SetAlertSuspend(alert, suspend)alert.Spec.Suspend = suspend
SetArtifactGeneratorSpec(obj, spec)obj.Spec = spec
SetBucketEndpoint(b, endpoint)b.Spec.Endpoint = endpoint
SetBucketInsecure(b, insecure)b.Spec.Insecure = insecure
SetBucketInterval(b, interval)b.Spec.Interval = interval
SetBucketName(b, name)b.Spec.BucketName = name
SetBucketPrefix(b, prefix)b.Spec.Prefix = prefix
SetBucketProvider(b, provider)b.Spec.Provider = provider
SetBucketRegion(b, region)b.Spec.Region = region
SetBucketSpec(obj, spec)obj.Spec = spec
SetBucketSuspend(b, suspend)b.Spec.Suspend = suspend
SetCommitAuthor(spec, author)spec.Author = author
SetCommitMessageTemplate(spec, tpl)spec.MessageTemplate = tpl
SetCommitMessageTemplateValues(spec, values)spec.MessageTemplateValues = values
SetCopyOperationStrategy(op, strategy)op.Strategy = strategy
SetCustomHealthCheckFailed(chk, expr)chk.HealthCheckExpressions.Failed = expr
SetCustomHealthCheckInProgress(chk, expr)chk.HealthCheckExpressions.InProgress = expr
SetExternalArtifactSpec(obj, spec)obj.Spec = spec
SetFluxInstanceDistribution(obj, dist)obj.Spec.Distribution = dist
SetFluxInstanceDistributionVariant(obj, variant)obj.Spec.Distribution.Variant = variant
SetFluxInstanceSpec(obj, spec)obj.Spec = spec
SetFluxReportDistribution(fr, dist)fr.Spec.Distribution = dist
SetFluxReportSpec(obj, spec)obj.Spec = spec
SetGitCheckoutReference(spec, ref)spec.Reference = ref
SetGitRepositoryInterval(gr, interval)gr.Spec.Interval = interval
SetGitRepositoryProvider(gr, provider)gr.Spec.Provider = provider
SetGitRepositoryRecurseSubmodules(gr, recurse)gr.Spec.RecurseSubmodules = recurse
SetGitRepositoryServiceAccountName(gr, name)gr.Spec.ServiceAccountName = name
SetGitRepositorySparseCheckout(gr, paths)gr.Spec.SparseCheckout = paths
SetGitRepositorySpec(obj, spec)obj.Spec = spec
SetGitRepositorySuspend(gr, suspend)gr.Spec.Suspend = suspend
SetGitRepositoryURL(gr, url)gr.Spec.URL = url
SetGitSpecCommit(spec, commit)spec.Commit = commit
SetHelmChartChart(hc, chart)hc.Spec.Chart = chart
SetHelmChartIgnoreMissingValuesFiles(hc, ignore)hc.Spec.IgnoreMissingValuesFiles = ignore
SetHelmChartInterval(hc, interval)hc.Spec.Interval = interval
SetHelmChartReconcileStrategy(hc, strategy)hc.Spec.ReconcileStrategy = strategy
SetHelmChartSourceRef(hc, ref)hc.Spec.SourceRef = ref
SetHelmChartSpec(obj, spec)obj.Spec = spec
SetHelmChartSuspend(hc, suspend)hc.Spec.Suspend = suspend
SetHelmChartValuesFiles(hc, files)hc.Spec.ValuesFiles = files
SetHelmChartVersion(hc, version)hc.Spec.Version = version
SetHelmReleaseInterval(obj, interval)obj.Spec.Interval = interval
SetHelmReleaseReleaseName(obj, name)obj.Spec.ReleaseName = name
SetHelmReleaseServiceAccountName(obj, name)obj.Spec.ServiceAccountName = name
SetHelmReleaseSpec(obj, spec)obj.Spec = spec
SetHelmReleaseStorageNamespace(obj, ns)obj.Spec.StorageNamespace = ns
SetHelmReleaseSuspend(obj, suspend)obj.Spec.Suspend = suspend
SetHelmReleaseTargetNamespace(obj, ns)obj.Spec.TargetNamespace = ns
SetHelmRepositoryInsecure(hr, insecure)hr.Spec.Insecure = insecure
SetHelmRepositoryInterval(hr, interval)hr.Spec.Interval = interval
SetHelmRepositoryPassCredentials(hr, v)hr.Spec.PassCredentials = v
SetHelmRepositoryProvider(hr, provider)hr.Spec.Provider = provider
SetHelmRepositorySpec(obj, spec)obj.Spec = spec
SetHelmRepositorySuspend(hr, suspend)hr.Spec.Suspend = suspend
SetHelmRepositoryType(hr, typ)hr.Spec.Type = typ
SetHelmRepositoryURL(hr, url)hr.Spec.URL = url
SetImageRefDigest(ref, digest)ref.Digest = digest
SetImageRefName(ref, name)ref.Name = name
SetImageRefTag(ref, tag)ref.Tag = tag
SetImageUpdateAutomationInterval(auto, interval)auto.Spec.Interval = interval
SetImageUpdateAutomationSourceRef(auto, ref)auto.Spec.SourceRef = ref
SetImageUpdateAutomationSpec(obj, spec)obj.Spec = spec
SetImageUpdateAutomationSuspend(auto, suspend)auto.Spec.Suspend = suspend
SetKustomizationDeletionPolicy(k, policy)k.Spec.DeletionPolicy = policy
SetKustomizationForce(k, force)k.Spec.Force = force
SetKustomizationIgnoreMissingComponents(k, ignore)k.Spec.IgnoreMissingComponents = ignore
SetKustomizationInterval(k, interval)k.Spec.Interval = interval
SetKustomizationNamePrefix(k, prefix)k.Spec.NamePrefix = prefix
SetKustomizationNameSuffix(k, suffix)k.Spec.NameSuffix = suffix
SetKustomizationPath(k, path)k.Spec.Path = path
SetKustomizationPrune(k, prune)k.Spec.Prune = prune
SetKustomizationServiceAccountName(k, name)k.Spec.ServiceAccountName = name
SetKustomizationSourceRef(k, ref)k.Spec.SourceRef = ref
SetKustomizationSpec(obj, spec)obj.Spec = spec
SetKustomizationSuspend(k, suspend)k.Spec.Suspend = suspend
SetKustomizationTargetNamespace(k, namespace)k.Spec.TargetNamespace = namespace
SetKustomizationWait(k, wait)k.Spec.Wait = wait
SetOCIRepositoryInsecure(or, insecure)or.Spec.Insecure = insecure
SetOCIRepositoryInterval(or, interval)or.Spec.Interval = interval
SetOCIRepositoryProvider(or, provider)or.Spec.Provider = provider
SetOCIRepositoryServiceAccountName(or, name)or.Spec.ServiceAccountName = name
SetOCIRepositorySpec(obj, spec)obj.Spec = spec
SetOCIRepositorySuspend(or, suspend)or.Spec.Suspend = suspend
SetOCIRepositoryURL(or, url)or.Spec.URL = url
SetObservedPolicies(auto, policies)auto.Status.ObservedPolicies = policies
SetOutputArtifactOriginRevision(out, originRevision)out.OriginRevision = originRevision
SetOutputArtifactRevision(out, revision)out.Revision = revision
SetProviderAddress(provider, address)provider.Spec.Address = address
SetProviderChannel(provider, channel)provider.Spec.Channel = channel
SetProviderProxy(provider, proxy)provider.Spec.Proxy = proxy
SetProviderSpec(obj, spec)obj.Spec = spec
SetProviderSuspend(provider, suspend)provider.Spec.Suspend = suspend
SetProviderType(provider, t)provider.Spec.Type = t
SetProviderUsername(provider, username)provider.Spec.Username = username
SetPushBranch(spec, branch)spec.Branch = branch
SetPushOptions(spec, opts)spec.Options = opts
SetPushRefspec(spec, refspec)spec.Refspec = refspec
SetReceiverSpec(obj, spec)obj.Spec = spec
SetReceiverSuspend(receiver, suspend)receiver.Spec.Suspend = suspend
SetReceiverType(receiver, t)receiver.Spec.Type = t
SetResourceSetInputProviderServiceAccountName(obj, name)obj.Spec.ServiceAccountName = name
SetResourceSetInputProviderSpec(obj, spec)obj.Spec = spec
SetResourceSetInputProviderType(obj, typ)obj.Spec.Type = typ
SetResourceSetInputProviderURL(obj, url)obj.Spec.URL = url
SetResourceSetResourcesTemplate(rs, tpl)rs.Spec.ResourcesTemplate = tpl
SetResourceSetServiceAccountName(rs, name)rs.Spec.ServiceAccountName = name
SetResourceSetSpec(obj, spec)obj.Spec = spec
SetResourceSetWait(rs, wait)rs.Spec.Wait = wait
SetScheduleTimeZone(s, tz)s.TimeZone = tz
SetScheduleWindow(s, d)s.Window = d
SetSourceReferenceNamespace(ref, namespace)ref.Namespace = namespace
SetUpdateStrategyName(spec, name)spec.Strategy = name
SetUpdateStrategyPath(spec, path)spec.Path = path

pkg/kubernetes/metallb (15)

RemovedReplacement
SetBFDProfileSpec(obj, spec)obj.Spec = spec
SetBGPAdvertisementLocalPref(obj, pref)obj.Spec.LocalPref = pref
SetBGPAdvertisementSpec(obj, spec)obj.Spec = spec
SetBGPPeerBFDProfile(obj, profile)obj.Spec.BFDProfile = profile
SetBGPPeerEBGPMultiHop(obj, multi)obj.Spec.EBGPMultiHop = multi
SetBGPPeerHoldTime(obj, d)obj.Spec.HoldTime = d
SetBGPPeerKeepaliveTime(obj, d)obj.Spec.KeepaliveTime = d
SetBGPPeerPassword(obj, pw)obj.Spec.Password = pw
SetBGPPeerPort(obj, port)obj.Spec.Port = port
SetBGPPeerRouterID(obj, id)obj.Spec.RouterID = id
SetBGPPeerSpec(obj, spec)obj.Spec = spec
SetBGPPeerSrcAddress(obj, addr)obj.Spec.SrcAddress = addr
SetIPAddressPoolAvoidBuggyIPs(obj, avoid)obj.Spec.AvoidBuggyIPs = avoid
SetIPAddressPoolSpec(obj, spec)obj.Spec = spec
SetL2AdvertisementSpec(obj, spec)obj.Spec = spec

pkg/kubernetes/prometheus (9)

RemovedReplacement
SetPodMonitorJobLabel(obj, label)obj.Spec.JobLabel = label
SetPodMonitorNamespaceSelector(obj, ns)obj.Spec.NamespaceSelector = ns
SetPodMonitorSelector(obj, selector)obj.Spec.Selector = selector
SetPodMonitorSpec(obj, spec)obj.Spec = spec
SetPrometheusRuleSpec(obj, spec)obj.Spec = spec
SetServiceMonitorJobLabel(obj, label)obj.Spec.JobLabel = label
SetServiceMonitorNamespaceSelector(obj, ns)obj.Spec.NamespaceSelector = ns
SetServiceMonitorSelector(obj, selector)obj.Spec.Selector = selector
SetServiceMonitorSpec(obj, spec)obj.Spec = spec

Per-kind pod-template passthroughs folded onto PodSpec

A workload kind’s pod template is a corev1.PodSpec. One helper per pod-template field, per workload kind, is five copies of the same line — so the PodSpec-level helper is the only one kept and the caller names the template it means. The 51 per-kind passthroughs below are removed.

The PodSpec appenders lose their error return in the same change: they are void and panic on a nil *PodSpec or a nil element, matching the PodSpec setters, which already did. AddPodSpecTopologySpreadConstraints no longer silently ignores a nil constraint.

The receiver expression is the same for every row of a kind:

KindPod template
Deployment&dep.Spec.Template.Spec
StatefulSet&sts.Spec.Template.Spec
DaemonSet&ds.Spec.Template.Spec
Job&job.Spec.Template.Spec
CronJob&cj.Spec.JobTemplate.Spec.Template.Spec

Below, spec stands for that expression.

Deployment (9)

RemovedReplacement
SetDeploymentPodSpec(dep, s)dep.Spec.Template.Spec = *s
AddDeploymentContainer(dep, c)AddPodSpecContainer(spec, c)
AddDeploymentInitContainer(dep, c)AddPodSpecInitContainer(spec, c)
AddDeploymentVolume(dep, v)AddPodSpecVolume(spec, v)
AddDeploymentImagePullSecret(dep, s)AddPodSpecImagePullSecret(spec, s)
AddDeploymentToleration(dep, t)AddPodSpecToleration(spec, t)
AddDeploymentTopologySpreadConstraints(dep, c)AddPodSpecTopologySpreadConstraints(spec, c)
SetDeploymentSecurityContext(dep, sc)SetPodSpecSecurityContext(spec, sc)
SetDeploymentAffinity(dep, aff)SetPodSpecAffinity(spec, aff)

StatefulSet (11)

RemovedReplacement
SetStatefulSetPodSpec(sts, s)sts.Spec.Template.Spec = *s
AddStatefulSetContainer(sts, c)AddPodSpecContainer(spec, c)
AddStatefulSetInitContainer(sts, c)AddPodSpecInitContainer(spec, c)
AddStatefulSetVolume(sts, v)AddPodSpecVolume(spec, v)
AddStatefulSetImagePullSecret(sts, s)AddPodSpecImagePullSecret(spec, s)
AddStatefulSetToleration(sts, t)AddPodSpecToleration(spec, t)
AddStatefulSetTopologySpreadConstraints(sts, c)AddPodSpecTopologySpreadConstraints(spec, c)
SetStatefulSetSecurityContext(sts, sc)SetPodSpecSecurityContext(spec, sc)
SetStatefulSetAffinity(sts, aff)SetPodSpecAffinity(spec, aff)
SetStatefulSetServiceAccountName(sts, n)spec.ServiceAccountName = n
SetStatefulSetNodeSelector(sts, ns)spec.NodeSelector = ns

DaemonSet (11)

RemovedReplacement
SetDaemonSetPodSpec(ds, s)ds.Spec.Template.Spec = *s
AddDaemonSetContainer(ds, c)AddPodSpecContainer(spec, c)
AddDaemonSetInitContainer(ds, c)AddPodSpecInitContainer(spec, c)
AddDaemonSetVolume(ds, v)AddPodSpecVolume(spec, v)
AddDaemonSetImagePullSecret(ds, s)AddPodSpecImagePullSecret(spec, s)
AddDaemonSetToleration(ds, t)AddPodSpecToleration(spec, t)
AddDaemonSetTopologySpreadConstraints(ds, c)AddPodSpecTopologySpreadConstraints(spec, c)
SetDaemonSetSecurityContext(ds, sc)SetPodSpecSecurityContext(spec, sc)
SetDaemonSetAffinity(ds, aff)SetPodSpecAffinity(spec, aff)
SetDaemonSetServiceAccountName(ds, n)spec.ServiceAccountName = n
SetDaemonSetNodeSelector(ds, ns)spec.NodeSelector = ns

Job (11)

RemovedReplacement
SetJobPodSpec(job, s)job.Spec.Template.Spec = *s
AddJobContainer(job, c)AddPodSpecContainer(spec, c)
AddJobInitContainer(job, c)AddPodSpecInitContainer(spec, c)
AddJobVolume(job, v)AddPodSpecVolume(spec, v)
AddJobImagePullSecret(job, s)AddPodSpecImagePullSecret(spec, s)
AddJobToleration(job, t)AddPodSpecToleration(spec, t)
AddJobTopologySpreadConstraint(job, c)AddPodSpecTopologySpreadConstraints(spec, c)
SetJobSecurityContext(job, sc)SetPodSpecSecurityContext(spec, sc)
SetJobAffinity(job, aff)SetPodSpecAffinity(spec, aff)
SetJobServiceAccountName(job, n)spec.ServiceAccountName = n
SetJobNodeSelector(job, ns)spec.NodeSelector = ns

CronJob (9)

RemovedReplacement
SetCronJobPodSpec(cj, s)cj.Spec.JobTemplate.Spec.Template.Spec = *s
AddCronJobContainer(cj, c)AddPodSpecContainer(spec, c)
AddCronJobInitContainer(cj, c)AddPodSpecInitContainer(spec, c)
AddCronJobVolume(cj, v)AddPodSpecVolume(spec, v)
AddCronJobImagePullSecret(cj, s)AddPodSpecImagePullSecret(spec, s)
AddCronJobToleration(cj, t)AddPodSpecToleration(spec, t)
AddCronJobTopologySpreadConstraint(cj, c)AddPodSpecTopologySpreadConstraints(spec, c)
SetCronJobSecurityContext(cj, sc)SetPodSpecSecurityContext(spec, sc)
SetCronJobAffinity(cj, aff)SetPodSpecAffinity(spec, aff)

Effect on the exclusion list

Of the 51, 47 were on the exclusion list; SetDeploymentSecurityContext, SetDeploymentAffinity, SetCronJobSecurityContext and SetCronJobAffinity already passed as class (b) pointer assignments and are removed as duplicates, not as contract violations. Dropping those 47 plus the seven PodSpec appenders that no longer return an error takes the file from 87 tolerated helpers to 33.

The seven PodSpec appender signatures

These are the survivors every table above forwards to, and their own signatures changed: the error return is gone, and a nil argument panics instead of returning a sentinel. Code that checked the error no longer compiles, so each is listed by name with its before and after.

BeforeAfter
AddPodSpecContainer(spec, c) errorAddPodSpecContainer(spec, c)
AddPodSpecInitContainer(spec, c) errorAddPodSpecInitContainer(spec, c)
AddPodSpecEphemeralContainer(spec, c) errorAddPodSpecEphemeralContainer(spec, c)
AddPodSpecVolume(spec, v) errorAddPodSpecVolume(spec, v)
AddPodSpecImagePullSecret(spec, s) errorAddPodSpecImagePullSecret(spec, s)
AddPodSpecToleration(spec, t) errorAddPodSpecToleration(spec, t)
AddPodSpecTopologySpreadConstraints(spec, c) errorAddPodSpecTopologySpreadConstraints(spec, c)

Drop the error check; there is nothing left to check.

// before
if err := AddPodSpecEphemeralContainer(spec, ec); err != nil {
    return err
}

// after
AddPodSpecEphemeralContainer(spec, ec)

A nil spec or nil element panics, which is what the contract requires of every sugar helper. AddPodSpecTopologySpreadConstraints is the one whose nil-element behaviour also changed — it used to return nil and silently drop the constraint; see its own entry below.

The pkg/errors sentinels those helpers returned (ErrNilDeployment, ErrNilDaemonSet, ErrNilStatefulSet, ErrNilJob, ErrNilCronJob, ErrNilVolume, ErrNilToleration, ErrNilImagePullSecret, ErrNilInitContainer, ErrNilEphemeralContainer, ErrNilSpec) had no producer left in the module. They were removed by go-kure/kure#758, together with every other pkg/errors sentinel that no kure code returned (61 in all), and a test now fails if an exported sentinel has no producer. ErrNilContainer and ErrNilPodSpec stay: pkg/kubernetes/psa.go’s validators return them by design.

Error-returning helpers rewritten as void

The purity rule (§4) allows no error return: a nil receiver panics rather than being reported, so a result slot has nothing to carry, and validation inside a helper is defaulting by another name.

ResourceRequirements quantities

The eight quantity setters took a string and parsed it, so each could fail on the parse and each carried an error. Parsing belongs to the caller, and once the value arrives as a resource.Quantity the body is a map insert with a nil-map guard — class (a), void. The six typed convenience wrappers are the same insert with a constant key and are removed with the rest of the per-field duplication.

RemovedReplacement
SetResourceRequestCPU(rr, "100m")SetResourceRequest(rr, corev1.ResourceCPU, resource.MustParse("100m"))
SetResourceRequestMemory(rr, "256Mi")SetResourceRequest(rr, corev1.ResourceMemory, resource.MustParse("256Mi"))
SetResourceRequestEphemeralStorage(rr, "10Gi")SetResourceRequest(rr, corev1.ResourceEphemeralStorage, resource.MustParse("10Gi"))
SetResourceLimitCPU(rr, "500m")SetResourceLimit(rr, corev1.ResourceCPU, resource.MustParse("500m"))
SetResourceLimitMemory(rr, "1Gi")SetResourceLimit(rr, corev1.ResourceMemory, resource.MustParse("1Gi"))
SetResourceLimitEphemeralStorage(rr, "20Gi")SetResourceLimit(rr, corev1.ResourceEphemeralStorage, resource.MustParse("20Gi"))
Signature changedFromTo
SetResourceRequest(rr, name, value string) error(rr, name, qty resource.Quantity)
SetResourceLimit(rr, name, value string) error(rr, name, qty resource.Quantity)

resource.MustParse for a literal, resource.ParseQuantity when the text comes from configuration and the error has somewhere to go.

SetHelmReleaseValuesFromMap

Signature changedFromTo
fluxcd.SetHelmReleaseValuesFromMap(obj, values map[string]any) error(obj, values map[string]any)

Its only error came from json.Marshal, which fails on a map holding a channel, a function or a NaN — a programming error, so it panics. The body stays class (b): a one-field literal, built from the caller’s map, assigned to the pointer field Spec.Values.

A sugar helper cannot hand back a marshalling failure, so values that could hold something encoding/json refuses must be marshalled by the caller and passed to SetHelmReleaseValues, which takes already-encoded JSON. The panic is reachable from a channel, a function, a cyclic structure, or a NaN or ±Inf float — and the float case is reachable from YAML, so “it was decoded from a config file” is not on its own a guarantee. A decoder that produces Go floats directly decodes the legal YAML scalar .NaN into a float64 NaN and returns no error (gopkg.in/yaml.v3@v3.0.1 decode_test.go:919-923, the library’s own test). A decoder that round-trips through JSON does rule it out, but by failing rather than by sanitising: sigs.k8s.io/yaml marshals the decoded object to JSON and hands you that error (sigs.k8s.io/yaml@v1.6.0 yaml.go:174-176), so the NaN never reaches your map. Reaching map[string]any by any other route needs the caller’s own check, or SetHelmReleaseValues.

These take the exclusion list from 33 tolerated helpers to 24.

Helpers that wrote more than the field they named

Three survivors of the passes above kept their names but broke §4’s rule that a helper touches no field the caller did not name. Each is resolved by narrowing the write, not by widening the contract.

PodDisruptionBudget availability

Spec.MinAvailable and Spec.MaxUnavailable are mutually exclusive upstream, and each setter used to nil the other. That made the pair order-dependent and hid a write the caller never asked for; it also classified as a nilClear, which no admission class covers. Both now assign only their own field.

Behaviour changeBeforeAfter
SetPDBMinAvailable(pdb, v)sets MinAvailable, clears MaxUnavailablesets MinAvailable only
SetPDBMaxUnavailable(pdb, v)sets MaxUnavailable, clears MinAvailablesets MaxUnavailable only

Setting both now produces a PodDisruptionBudget the API server rejects. Enforcing the exclusion is upstream’s job; switching from one to the other is an explicit two statements at the call site.

Pod Security Admission labels

SetNamespacePSALabels failed §4 twice: it delegated every write to AddNamespaceLabel so it contained no field write of its own, and it was built out of if level != "" { set(level) } — the conditional-write shape the contract names outright. Its replacement writes nothing at all.

RemovedReplacement
SetNamespacePSALabels(ns, enforce, warn, audit, version)for k, v := range PSALabels(enforce, warn, audit, version) { AddLabel(ns, k, v) }

PSALabels(enforce, warn, audit PSALevel, version string) map[string]string returns up to six labels and touches no object. One argument expanding into a family of labels is not something a Set<Field> helper may hide, so the expansion became a value helper and the write stayed with the generic AddLabel. Skipping an empty mode is honest in a function whose result is the label set, where the same skip inside a setter was a silent partial write.

No merge helper was added for this. The generic metadata set is fixed at four by name (§5) and the exempt list in the admission test does not grow, so a map-merging variant would have been a contract change to save one loop.

volsync trigger, spec and mover setters

pkg/kubernetes/volsync carried ten Set* helpers alongside a config-struct constructor that already covers the same fields. Nine are removed.

Release 2 retired that config-struct constructor in turn, so the ReplicationSourceConfig.* / ReplicationDestinationConfig.* replacements named below no longer exist either. Where a row also names an rs.Spec.* / rd.Spec.* assignment, that assignment is the one that survives. The six rows that name only a Config field (the four trigger setters and the two mover setters) are replaced by rs.Spec.Trigger = &volsyncv1alpha1.ReplicationSourceTriggerSpec{...} / rd.Spec.Trigger = &volsyncv1alpha1.ReplicationDestinationTriggerSpec{...} and by setting one mover arm (rs.Spec.Restic, rs.Spec.Rsync, …) directly; the release 2 migration notes map each trigger field and mover variant to its upstream field.

RemovedReplacement
SetReplicationSourceSourcePVC(rs, pvc)ReplicationSourceConfig.SourcePVC, or rs.Spec.SourcePVC = pvc
SetReplicationSourcePaused(rs, b)ReplicationSourceConfig.Paused, or rs.Spec.Paused = b
SetReplicationDestinationPaused(rd, b)ReplicationDestinationConfig.Paused, or rd.Spec.Paused = b
SetReplicationSourceSchedule(rs, s)ReplicationSourceConfig.Trigger
SetReplicationSourceManualTrigger(rs, s)ReplicationSourceConfig.Trigger
SetReplicationDestinationSchedule(rd, s)ReplicationDestinationConfig.Trigger
SetReplicationDestinationManualTrigger(rd, s)ReplicationDestinationConfig.Trigger
SetReplicationSourceMover(rs, m)ReplicationSourceConfig.Mover
SetReplicationDestinationMover(rd, m)ReplicationDestinationConfig.Mover

The three spec setters were bare field assignments. The four trigger setters nil-initialised Spec.Trigger and then wrote one of its fields, leaving the sibling trigger field standing — their doc comments claimed to replace the whole trigger and did not.

The two mover setters were the substantive removal. Each cleared every mover arm of its kind and then set one — six on a source, five on a destination, which has no Syncthing mover — a verbatim duplicate of the type switch in ReplicationSource / ReplicationDestination. A sealed-union discriminator is a multi-field write by nature, so it belongs where the invariant is already owned — the constructor — not behind a Set<Field> name. The constructor’s own tests exercised that switch, including a typed-nil mover for three of its eleven arms. (Release 2 retired the constructor and its sealed mover types; see the release 2 migration notes.)

AddSyncthingPeer stayed: appending to cfg.Peers is class (a). Its nil handling is a behaviour break in its own right:

Behaviour changeBeforeAfter
AddSyncthingPeer(nil, addr, id, introducer)if cfg == nil { return } — the peer is silently discardedpanics with AddSyncthingPeer: cfg must not be nil (since release 2, which takes the upstream spec: spec must not be nil)

A nil receiver is a programming error, and swallowing the caller’s write is the one outcome that cannot be diagnosed from the output. Every other appender in the package already panicked; this one no longer differs.

AddPodSpecTopologySpreadConstraints

Named here because it is a deliberate behaviour change rather than a signature one. It previously returned early on a nil constraint, discarding the caller’s write without a word. It now panics, like the six other appenders in podspec.go — a nil element is a programming error, and one appender silently dropping input while the rest reject it was the worse inconsistency.

These take the exclusion list from 24 tolerated helpers to 11, all of them ConfigMap helpers.

ConfigMap helpers un-delegated

All eleven ConfigMap helpers in pkg/kubernetes were one-line forwarders into internal/kubernetes. A helper that only calls another function contains no field write, so every one of them classified as inadmissible however class-shaped the code at the far end was. The bodies now live in pkg/kubernetes/configmap.go, with the nil-receiver panic the rest of the package uses.

Four are removed rather than inlined:

RemovedReplacement
SetConfigMapData(cm, data)cm.Data = data
SetConfigMapBinaryData(cm, data)cm.BinaryData = data
AddConfigMapDataMap(cm, data)for k, v := range data { AddConfigMapData(cm, k, v) }
AddConfigMapBinaryDataMap(cm, data)for k, v := range data { AddConfigMapBinaryData(cm, k, v) }

The first two are bare field assignments. The two merges are not admissible in any spelling: class (a) is a single insert whose value comes from the caller, and neither maps.Copy(cm.Data, data) nor an explicit for k, v := range data loop satisfies it — both were tried against the classifier and both stayed inadmissible. Rather than widen a class for one shape that appears twice in the whole tree, merging is a loop at the call site, the same answer PSA labels got.

AddConfigMapData, AddConfigMapBinaryData and SetConfigMapImmutable survive inlined: the first two are class (a) map inserts with a nil-map guard, the third a class (b) pointer assignment. AddConfigMapLabel, AddConfigMapAnnotation, SetConfigMapLabels and SetConfigMapAnnotations are handled with the rest of the per-kind metadata helpers below.

pkg/kubernetes/configmap.go was the only importer of internal/kubernetes, so that package had no callers left inside the module. It has since been removed (issue go-kure/kure#756); as an internal/ package it carried no public stability guarantee.

That leaves 2 tolerated helpers, both ConfigMap metadata.

Per-kind metadata helpers removed

The contract admits exactly four generic metadata helpers by name — AddLabel, SetLabels, AddAnnotation, SetAnnotations — and that set never grows. They take a metav1.Object, so they already reach every kind in the module, including kinds kure has never heard of. The thirty-two per-kind helpers below reached one kind each and are removed.

Every one of them has the same replacement shape, so the tables below give the kinds rather than repeating one row per function:

// before
cnpg.AddClusterLabel(cluster, "env", "prod")
certmanager.AddIssuerAnnotation(issuer, "note", "production")

// after
kubernetes.AddLabel(cluster, "env", "prod")
kubernetes.AddAnnotation(issuer, "note", "production")

Every removed symbol, so that searching this file for an old name finds it:

PackageRemovedReplacement
pkg/kubernetesAddConfigMapLabel, AddNamespaceLabel, AddServiceLabel, AddServiceAccountLabelkubernetes.AddLabel(obj, key, value)
pkg/kubernetesAddConfigMapAnnotation, AddNamespaceAnnotation, AddServiceAnnotation, AddServiceAccountAnnotationkubernetes.AddAnnotation(obj, key, value)
pkg/kubernetesSetConfigMapLabels(cm, labels)cm.Labels = labels
pkg/kubernetesSetConfigMapAnnotations(cm, anns)cm.Annotations = anns
pkg/kubernetes/certmanagerAddCertificateLabel, AddIssuerLabel, AddClusterIssuerLabelkubernetes.AddLabel(obj, key, value)
pkg/kubernetes/certmanagerAddCertificateAnnotation, AddIssuerAnnotation, AddClusterIssuerAnnotationkubernetes.AddAnnotation(obj, key, value)
pkg/kubernetes/cnpgAddClusterLabel, AddDatabaseLabel, AddObjectStoreLabel, AddScheduledBackupLabelkubernetes.AddLabel(obj, key, value)
pkg/kubernetes/cnpgAddClusterAnnotation, AddDatabaseAnnotation, AddObjectStoreAnnotation, AddScheduledBackupAnnotationkubernetes.AddAnnotation(obj, key, value)
pkg/kubernetes/externalsecretsAddExternalSecretLabel, AddSecretStoreLabel, AddClusterSecretStoreLabelkubernetes.AddLabel(obj, key, value)
pkg/kubernetes/externalsecretsAddExternalSecretAnnotation, AddSecretStoreAnnotation, AddClusterSecretStoreAnnotationkubernetes.AddAnnotation(obj, key, value)
pkg/kubernetes/fluxcdAddHelmReleaseLabelkubernetes.AddLabel(obj, key, value)
pkg/kubernetes/fluxcdAddHelmReleaseAnnotationkubernetes.AddAnnotation(obj, key, value)

Thirty of the thirty-two take the generic helper unchanged — same arguments, same nil-map initialisation, same result. SetConfigMapLabels and SetConfigMapAnnotations are whole-map replacements: a generic pair taking a whole map would do the same thing, but a bare field assignment is what the contract prefers for a bare write.

Three groups of metadata-shaped helpers stay, because none of them writes ObjectMeta:

KeptField it writes
cilium.SetCiliumNetworkPolicyLabels, cilium.SetCiliumClusterwideNetworkPolicyLabelsspec.labels (labels.LabelArray)
prometheus.AddServiceMonitorTargetLabel, prometheus.AddPodMonitorPodTargetLabelspec.targetLabels / spec.podTargetLabels ([]string)
fluxcd.AddCommonMetadataLabel, fluxcd.AddCommonMetadataAnnotationspec.commonMetadata on a Kustomization — a spec sub-type, not a metav1.Object, so the generic helpers cannot reach it

With these gone the exclusion list is empty: every exported Set*/Add* under pkg/kubernetes now satisfies the contract, and testdata/admission_exclusions.txt records no tolerated helper at all.

Flux workflow defaults, declared by the defaults PR

Once the constructors are identity-only and the nine sub-type constructors above are gone, pkg/kubernetes injects nothing through a constructor except in the hand-written sub-type helpers that survive this release — CreateResourceRequirements still initialises empty Requests/Limits maps, for one (see Sub-type constructors ). The two sections above list everything that was removed. pkg/stack/fluxcd is a different case. It is a workflow layer above the builders and may legitimately hold opinions, so most of its twenty-six injected values are not deleted:

DispositionSites
declared inputs with exported named defaults16
fixed by a CRD constraint; not a default1
pass-through tri-state inputs4
deferred to the package that owns the value3
never defaults at all; removed outright2

The sixteen exported-default sites collapse onto eleven identifiers. Interval, namespace, source name and the Flux directory name were each written as a separate literal in two places, and the source kind in two more with opposite polarities — five identifiers covering ten sites, plus six that appear once each. Those copies could have been changed apart. They now cannot.

The bootstrap name was also written in two places, and the defaults PR counted both under DefaultBootstrapName. One of them was the FluxInstance’s metadata.name, which the flux-operator CRD fixes at flux and rejects any other value for, so it was never a default: since go-kure/kure#847 it is FluxInstanceName and has its own entry below. DefaultBootstrapName keeps the one remaining site, the bootstrap Kustomization’s name.

defaults.go declares two further names that are not defaults. ModeGotk is one — nothing falls back to it. It exists so the bootstrap mode set has one authority: DefaultFluxMode is both the fallback for an empty FluxMode and the value GenerateBootstrap dispatches on, so the two cannot be changed apart into a state where an empty FluxMode resolves to a mode the switch rejects. FluxInstanceName is the other: a value the CRD requires, not an opinion the package holds, so it has no override.

The ticket’s own figure was twenty, and that number was too low. It counted resource_generator.go and bootstrap_generator.go only: layout_integrator.go holds five further sites and was never in the inventory, and the bootstrap Kustomization’s spec.path root was missed inside a file that was. The count above is recomputed from the package as it stands, and the two remaining categories below account for every site that is not simply renamed.

Exported so a consumer can see and override them

Every fallback either generator applies is now one of these identifiers in pkg/stack/fluxcd/defaults.go. Grep for the identifier to find every place its value can reach emitted YAML. How each one is overridden differs, and the paragraph after the table gives the split — four are copied into an exported generator field by the constructor, four yield to a named input, and three are fixed path segments with no override.

IdentifierValueReplaces the literal formerly at
DefaultInterval60 * time.MinuteNewResourceGenerator, NewBootstrapGenerator. It was also retained when a non-empty Bundle.Interval failed to parse; that is now a validation error (issue go-kure/kure#762)
DefaultNamespace"flux-system"both constructors
DefaultModelayout.KustomizationExplicitNewResourceGenerator
DefaultSourceName"flux-system"generateGitSource, generateOCISource
DefaultBootstrapName"flux-system"the bootstrap Kustomization’s name (and, until go-kure/kure#847, the FluxInstance’s — now fixed at FluxInstanceName, "flux", the only name the CRD accepts)
DefaultFluxMode"flux-operator"GenerateBootstrap’s empty-mode fallback, its dispatch switch, and SupportedBootstrapModes
DefaultSourceKind"OCIRepository"generateFluxSystemKustomization, generateFluxInstance
DefaultBootstrapPathRoot"manifests"generateFluxSystemKustomization (removed again by go-kure/kure#979: the gotk bootstrap Kustomization’s spec.path is now the root node’s directory, the one the FluxInstance’s sync.path names)
DefaultFluxDirName"flux-system"addSeparateFluxToLayout, twice
DefaultSourceRef"latest"generateOCISource
DefaultSyncPath"./"generateFluxInstance

DefaultBootstrapName also gains BootstrapGenerator.BootstrapName. It was the one default with no override path at all — BootstrapConfig carries no name input and the constant was applied directly at both emission sites — while the package documented every default as overrideable. Four identifiers are now copied into exported generator fields (DefaultInterval, DefaultNamespace, DefaultMode, DefaultBootstrapName); the rest are overridden by naming the corresponding input, and the three with no override (DefaultBootstrapPathRoot, DefaultFluxDirName, DefaultSyncPath) now say so instead of being described as though they had one. DefaultBootstrapPathRoot has since been removed (go-kure/kure#979), which leaves two.

Adding that field made a nameless object reachable for the first time, so an empty BootstrapName resolves back to DefaultBootstrapName at emission. Both names were previously a literal and could not be absent; a consumer that builds BootstrapGenerator as a struct literal rather than through NewBootstrapGenerator leaves the new field zero, and a Kustomization with an empty metadata.name is rejected by the API server. The field is an override, not a way to remove the name. (The FluxInstance no longer reads it: its name is fixed at FluxInstanceName since go-kure/kure#847.) DefaultNamespace and DefaultInterval have the same hazard on a struct literal, but they had it before this change as well, and resolving them at emission would alter existing behaviour rather than preserve it — so they are left as they were.

DefaultFluxDirName shares DefaultNamespace’s value and is a separate identifier on purpose: one is a path segment, the other a Kubernetes namespace, and renaming the namespace must not silently rename the directory.

Except for DefaultSourceKind, none of these changes emitted YAML — they change where the value comes from. DefaultSourceKind does change it, and has its own entry below. So does FluxInstanceName, which is not one of these identifiers: the FluxInstance is now named flux rather than flux-system (see Changed: the FluxInstance is named flux ).

Deferred, not renamed: three values this package does not own

Two sites in addSeparateFluxToLayout and normalizeRulesPlacement fell back to layout.FilePerResource and layout.FluxSeparate. Both are already declared by layout.DefaultLayoutRules() (pkg/stack/layout/types.go:154-163), which is also where the layout walker resolves unset options from (pkg/stack/layout/walker.go:42-43). They now read from that function rather than getting an identifier here, because a constant in pkg/stack/fluxcd would be a second copy of a value that can change independently — the failure mode the rest of this work exists to remove.

The third is the separate Flux layout’s own Mode, which was layout.KustomizationExplicit inline and is now left unset. It is deferred rather than named because it cannot take effect at that site at all: the layout writer resolves an unset mode to KustomizationExplicit (pkg/stack/layout/manifest.go:324-326), and the separate Flux layout never gains children, so the branch a mode selects lists the resource files either way (manifest.go:352). Seeding it from DefaultMode would have made that identifier look overrideable through ResourceGenerator.Mode at a site that never reads the field — the same false-override claim this work removes elsewhere.

Changed: one resolved source kind, and the dangling reference it hid

The kind of source object bootstrap emits was decided in three places, and they did not agree. generateSource emitted a GitRepository only when SourceKind == "GitRepository" and an OCIRepository for everything else, including the empty string. generateFluxInstance’s sync block used the same test. But generateFluxSystemKustomization used the opposite one — it wrote sourceRef.Kind: OCIRepository only when SourceKind == "OCIRepository", and GitRepository otherwise.

The three agreed only when SourceKind named a kind exactly:

SourceKindEmitted sourcesourceRef.Kind beforeafter
"GitRepository"GitRepositoryGitRepositoryGitRepository
"OCIRepository"OCIRepositoryOCIRepositoryOCIRepository
""OCIRepositoryGitRepositoryOCIRepository
anything elseOCIRepositoryGitRepositoryOCIRepository

The last two rows are the same defect as the sourceRef.name one below, in the other field of the same reference: a Kustomization pointing at an object that was never created. An unnamed SourceKind is the default case, not an exotic one.

resolvedSourceKind is now the only place the decision is made, and all three sites call it. The exported default is DefaultSourceKind = "OCIRepository", which is what an unnamed kind actually yields. The previous DefaultBootstrapSourceKind = "GitRepository" named the losing side of the disagreement rather than the package’s behaviour, so it is gone rather than renamed.

Changed: the FluxInstance is named flux

In flux-operator mode the FluxInstance took its metadata.name from BootstrapGenerator.BootstrapName, the same field that names the bootstrap Kustomization. The flux-operator CRD admits exactly one name (x-kubernetes-validations rule self.metadata.name == 'flux'), so every bundle emitted with the default name was refused at admission with “the only accepted name for a FluxInstance is ‘flux’”. Since go-kure/kure#847 the name is the constant FluxInstanceName, and BootstrapName names the Kustomization only.

BootstrapNameFluxInstance name beforeafterbootstrap Kustomization name
unset (DefaultBootstrapName)flux-system — refusedfluxflux-system
fluxfluxfluxflux
anything elsethat value — refusedfluxthat value

gotk mode emits no FluxInstance and is unchanged.

Removed: the placeholder registry URL

generateOCISource substituted oci://registry.example.com/flux-system when BootstrapConfig.SourceURL was empty, so a missing input produced a manifest pointing at a registry that does not exist. An absent input is an error, not a placeholder: generateSource, generateGitSource and generateOCISource now return (client.Object, error) and reject an empty SourceURL naming the field.

The reachable behaviour is unchanged for every caller that supplied a URL, and GenerateBootstrap still emits no source at all when SourceURL is empty — that means “the caller supplies the source”, and it is not an error.

Removed: a dangling sourceRef

The bootstrap Kustomization hardcoded spec.sourceRef.name: flux-system while the source object it points at is named after the root node. With a named root node the Kustomization referenced a source that was never emitted. Both names now come from one derivation, so they cannot drift apart.

Root nodesourceRef.name beforeafterEmitted source’s name
named prodflux-systemprodprod
unnamedflux-systemflux-systemflux-system

Fixed: a nil root node panicked

Consolidating the name derivation exposed an adjacent defect. Every site that read the root node’s name tolerated a nil node except the bootstrap Kustomization’s spec.path, which dereferenced rootNode.Name directly — one line above the nil-guarded call for sourceRef.name, in the same struct literal. GenerateBootstrap(config, nil) in gotk mode therefore panicked, while the same call in flux-operator mode returned a valid FluxInstance.

rootName is now the single place the name is read, and returns "" for a nil or unnamed node. A nil root node yields spec.path: manifests and sourceRef.name: flux-system, which is what the unnamed-node case already produced. (Since go-kure/kure#979 both cases yield spec.path: ., the root of the source.)

Changed: prune and wait are pass-through inputs

Bundle.Prune and Bundle.Wait were already *bool. The generator collapsed them. They are now passed through untouched, and BootstrapConfig gains a Prune *bool so the bootstrap Kustomization has the same input. ResourceGenerator gains a Prune *bool for Kustomizations generated from a layout.ManifestLayout, which carries no prune setting of its own.

The two resolve differently, because their upstream tags differ. Both tags are in kustomize-controller’s api/v1/kustomization_types.go:

FieldUpstream tagDeclared atUnset input emits
KustomizationSpec.Prune+required, no omitemptykustomization_types.go:99-101prune: false — the key cannot be left out
KustomizationSpec.Wait+optional, omitemptykustomization_types.go:174-178nothing; Flux’s own default, false, applies

This is the behaviour change with teeth. An unset Prune used to emit prune: true, enabling destructive garbage collection for a caller who never asked for it. It now emits prune: false. A caller that wants garbage collection sets Prune explicitly:

prune := true
bundle.Prune = &prune

Changed: the umbrella no longer forces wait: true

An umbrella bundle (Bundle.Children non-empty) had spec.wait forced to true alongside the auto-generated spec.healthChecks entry per child. That was self-defeating: upstream’s own doc comment on the field says that when it is enabled “the HealthChecks are ignored” (kustomize-controller api/v1/kustomization_types.go:175-177), so the entries the generator had just built were inert. Removing the force is what makes them take effect, and the umbrella still becomes Ready only when every child is.

A caller that prefers upstream’s whole-of-resources health assessment sets Wait explicitly; that gates on the child Kustomizations too, and then the healthChecks entries are the ones upstream ignores.

stack.Bundle.Validate used to reject an explicit Wait=false on a bundle with Children. With the force gone that rejected a state whose emitted YAML is identical to the accepted unset one, since Wait carries omitempty. The check has since been dropped (issue go-kure/kure#760): an umbrella with Wait=false validates, exactly as an unset one does.