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:
| Before | After | Set 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:
| Class | What happens | How you find out |
|---|---|---|
| rejected | the API server refuses the manifest | at apply time, loudly |
| silent | the object is valid and means something else | in the cluster, eventually |
| no-op | output may change, cluster behaviour does not | nothing in the cluster differs |
| unsettled | not determinable from this repository’s dependencies | stated, 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.RestartPolicyNeverBoth 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.1pkg/k8s/apis/cilium.io/client/crds/v2/ciliumcidrgroups.yaml:48-60declarestype: arraywithminItems: 0andrequired: [externalCIDRs], and gives it neithernullablenor 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;nullis 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 thenullableflag 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 missingomitemptythis field is+optional(podmonitor_types.go:95), so a prunednullleaves 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 pinnedprometheus-operator/pkg/apis/monitoring@v0.93.1module contains 41 files and not one YAML or JSON among them. It ships Go types only. That is the difference fromcilium@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 ink8s.io/kubernetesvalidation, not a dependency of this repository. The type is not merely silent, it is inapplicable: the doc comment onVolumeResourceRequirements.Requestsstill 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 sharedResourceRequirementswith 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 namesSetServiceAccountAutomountToken(sa, false), which is what a caller who relied on the injectedfalseneeds 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.
| Constructor | Class | Removed default |
|---|---|---|
CreateConfigMap | no-op | data 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 |
CreateCronJob | rejected | spec.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 |
CreateDaemonSet | rejected | spec.selector.matchLabels.app: <name> (required, see above); spec.template.metadata.labels.app: <name>; the object’s own app label and annotation |
CreateDeployment | rejected | spec.selector.matchLabels.app: <name> (required, see above); spec.template.metadata.labels.app: <name>; the object’s own app label and annotation |
CreateHorizontalPodAutoscaler | no-op | the object’s own app label and annotation — its only injected default |
CreateHTTPRoute | no-op | empty spec.hostnames and spec.rules slices — both omitempty, so no output change; the object’s own app label and annotation |
CreateIngress | silent | spec.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 |
CreateJob | silent | spec.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 |
CreateNamespace | silent | the 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) |
CreateNetworkPolicy | silent | spec.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 |
CreatePersistentVolumeClaim | unsettled | spec.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 |
CreatePodDisruptionBudget | no-op | the object’s own app label and annotation — its only injected default |
CreateService | silent | the 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) |
CreateServiceAccount | unsettled | automountServiceAccountToken: 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 |
CreateStatefulSet | rejected | spec.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.CreateServiceMonitor | unsettled | empty 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.CreatePodMonitor | no-op | empty 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.CreatePrometheusRule | no-op | empty 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.CreateCiliumCIDRGroup | rejected | empty 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
omitemptyand is now empty:replicas: 0(look at this one twice — theomitemptysits 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 ownapplabel 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 — noomitemptyat all, or a struct-typed field, for whichomitemptydoes nothing (see below):spec.selectoron DaemonSet/Deployment/StatefulSet rendersnull(apps/v1/types.go:702,:426and:215respectively — one declaration per spec), NetworkPolicy’sspec.podSelectorrenders{}(networking/v1/types.go:69— a value type, so the zero struct, notnull), CronJob’sspec.schedulerenders""(batch/v1/types.go:794), the three CRD list fields rendernullwhere they rendered[], the PVC’sspec.resourcesrenders{}(core/v1/types.go:566), the Ingress’s wholespecrenders{}(networking/v1/types.go:261) now that the class name was its only content, and the pod template’smetadatarenders{}on all five pod-template kinds (core/v1/types.go:5834) now that theapplabel 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.
| Removed | Replacement |
|---|---|
CreateContainer(name string, image string, command []string, args []string) *corev1.Container | see below — it injected seven fields |
CreatePodSpec() *corev1.PodSpec | see below — it injected eleven |
CreateVolumeClaimTemplate(name string, opts VolumeClaimTemplateOptions) corev1.PersistentVolumeClaim | see below |
CreateIngressRule(host string) *netv1.IngressRule | see below |
CreateACMEIssuer(server, email string, key cmmeta.SecretKeySelector) *cmacme.ACMEIssuer | see below |
CreateACMEHTTP01Solver(serviceType corev1.ServiceType, class string) cmacme.ACMEChallengeSolver | see below |
CreateACMEDNS01SolverCloudflare(email string, token cmmeta.SecretKeySelector) cmacme.ACMEChallengeSolver | see below |
CreateACMEDNS01SolverRoute53(region string, key cmmeta.SecretKeySelector) cmacme.ACMEChallengeSolver | see below |
CreateACMEDNS01SolverGoogle(project string, sa *cmmeta.SecretKeySelector) cmacme.ACMEChallengeSolver | see 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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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:
| Kind | Pod 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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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)
| Removed | Replacement |
|---|---|
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.
| Before | After |
|---|---|
AddPodSpecContainer(spec, c) error | AddPodSpecContainer(spec, c) |
AddPodSpecInitContainer(spec, c) error | AddPodSpecInitContainer(spec, c) |
AddPodSpecEphemeralContainer(spec, c) error | AddPodSpecEphemeralContainer(spec, c) |
AddPodSpecVolume(spec, v) error | AddPodSpecVolume(spec, v) |
AddPodSpecImagePullSecret(spec, s) error | AddPodSpecImagePullSecret(spec, s) |
AddPodSpecToleration(spec, t) error | AddPodSpecToleration(spec, t) |
AddPodSpecTopologySpreadConstraints(spec, c) error | AddPodSpecTopologySpreadConstraints(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.
| Removed | Replacement |
|---|---|
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 changed | From | To |
|---|---|---|
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 changed | From | To |
|---|---|---|
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 change | Before | After |
|---|---|---|
SetPDBMinAvailable(pdb, v) | sets MinAvailable, clears MaxUnavailable | sets MinAvailable only |
SetPDBMaxUnavailable(pdb, v) | sets MaxUnavailable, clears MinAvailable | sets 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.
| Removed | Replacement |
|---|---|
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.
| Removed | Replacement |
|---|---|
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 change | Before | After |
|---|---|---|
AddSyncthingPeer(nil, addr, id, introducer) | if cfg == nil { return } — the peer is silently discarded | panics 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:
| Removed | Replacement |
|---|---|
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:
| Package | Removed | Replacement |
|---|---|---|
pkg/kubernetes | AddConfigMapLabel, AddNamespaceLabel, AddServiceLabel, AddServiceAccountLabel | kubernetes.AddLabel(obj, key, value) |
pkg/kubernetes | AddConfigMapAnnotation, AddNamespaceAnnotation, AddServiceAnnotation, AddServiceAccountAnnotation | kubernetes.AddAnnotation(obj, key, value) |
pkg/kubernetes | SetConfigMapLabels(cm, labels) | cm.Labels = labels |
pkg/kubernetes | SetConfigMapAnnotations(cm, anns) | cm.Annotations = anns |
pkg/kubernetes/certmanager | AddCertificateLabel, AddIssuerLabel, AddClusterIssuerLabel | kubernetes.AddLabel(obj, key, value) |
pkg/kubernetes/certmanager | AddCertificateAnnotation, AddIssuerAnnotation, AddClusterIssuerAnnotation | kubernetes.AddAnnotation(obj, key, value) |
pkg/kubernetes/cnpg | AddClusterLabel, AddDatabaseLabel, AddObjectStoreLabel, AddScheduledBackupLabel | kubernetes.AddLabel(obj, key, value) |
pkg/kubernetes/cnpg | AddClusterAnnotation, AddDatabaseAnnotation, AddObjectStoreAnnotation, AddScheduledBackupAnnotation | kubernetes.AddAnnotation(obj, key, value) |
pkg/kubernetes/externalsecrets | AddExternalSecretLabel, AddSecretStoreLabel, AddClusterSecretStoreLabel | kubernetes.AddLabel(obj, key, value) |
pkg/kubernetes/externalsecrets | AddExternalSecretAnnotation, AddSecretStoreAnnotation, AddClusterSecretStoreAnnotation | kubernetes.AddAnnotation(obj, key, value) |
pkg/kubernetes/fluxcd | AddHelmReleaseLabel | kubernetes.AddLabel(obj, key, value) |
pkg/kubernetes/fluxcd | AddHelmReleaseAnnotation | kubernetes.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:
| Kept | Field it writes |
|---|---|
cilium.SetCiliumNetworkPolicyLabels, cilium.SetCiliumClusterwideNetworkPolicyLabels | spec.labels (labels.LabelArray) |
prometheus.AddServiceMonitorTargetLabel, prometheus.AddPodMonitorPodTargetLabel | spec.targetLabels / spec.podTargetLabels ([]string) |
fluxcd.AddCommonMetadataLabel, fluxcd.AddCommonMetadataAnnotation | spec.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:
| Disposition | Sites |
|---|---|
| declared inputs with exported named defaults | 16 |
| fixed by a CRD constraint; not a default | 1 |
| pass-through tri-state inputs | 4 |
| deferred to the package that owns the value | 3 |
| never defaults at all; removed outright | 2 |
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.
| Identifier | Value | Replaces the literal formerly at |
|---|---|---|
DefaultInterval | 60 * time.Minute | NewResourceGenerator, 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 |
DefaultMode | layout.KustomizationExplicit | NewResourceGenerator |
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:
SourceKind | Emitted source | sourceRef.Kind before | after |
|---|---|---|---|
"GitRepository" | GitRepository | GitRepository | GitRepository |
"OCIRepository" | OCIRepository | OCIRepository | OCIRepository |
"" | OCIRepository | GitRepository | OCIRepository |
| anything else | OCIRepository | GitRepository | OCIRepository |
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.
BootstrapName | FluxInstance name before | after | bootstrap Kustomization name |
|---|---|---|---|
unset (DefaultBootstrapName) | flux-system — refused | flux | flux-system |
flux | flux | flux | flux |
| anything else | that value — refused | flux | that 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 node | sourceRef.name before | after | Emitted source’s name |
|---|---|---|---|
named prod | flux-system | prod | prod |
| unnamed | flux-system | flux-system | flux-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:
| Field | Upstream tag | Declared at | Unset input emits |
|---|---|---|---|
KustomizationSpec.Prune | +required, no omitempty | kustomization_types.go:99-101 | prune: false — the key cannot be left out |
KustomizationSpec.Wait | +optional, omitempty | kustomization_types.go:174-178 | nothing; 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 = &pruneChanged: 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.