Repository navigation
Replies: 5 comments
Revision 1: shutdown leaves the backlog, and the work queue's goroutine lingers rather than leaksTwo of the post's claims about client-go's work queue were wrong, and the first hid a bug in the prototype:
So the controller no longer drains its queue. When shutdown begins, its workers stop taking keys themselves, and the queue is shut down with Each section below replaces or adds to the part of the same name. The rest of the post stands. Shutdown
Leader electionOnly the bullet "A lost Lease ends the App" changes:
Design decisionsThese are added:
The prototypeThe first of the four things the prototype found is replaced, and the
The container tests against k3s pass unchanged with the new shutdown. |
Revision 2: a fifth open question, on controllers of built-in kindsEvery Open questionsQuestions 1–4 are as in the post. Question 5 is new:
|
Revision 3: every name humus defines starts with
|
| In the post | Now |
|---|---|
kube.object.name, on every reconcile's span |
humus.kube.object.name |
open question 2's option (a): instruments "under kube.controller." |
under humus.kube.controller. |
open question 2's option (a): kube.controller.name |
humus.kube.controller.name |
Wherever the post names one of them, read the new name. These keep their names:
- OpenTelemetry's own, such as
k8s.namespace.nameon the reconcile's span, andotelhttp's client instruments. - The span's name,
reconcile <Kind>, and the records' bodies, such asreconcile failed. They aren't attribute or instrument names.
Nothing else changes. Open question 2 is still open: this changes only the names that its option (a) would use. Revisions 1 and 2 use no telemetry names.
Revision 4: open questions 1 to 4 answeredThe answers:
Question 5 is still open. Each section below replaces or adds to the part of the same name. The rest of the post, with revisions 1 to 3, stands. SummaryThis bullet is added under "A reconcile is handed its object and returns its status":
ReconcilingStep 7 gains a bullet:
A panicked object is reconciled again only as any settled object is: when it, or a resource it owns, changes, when the resync comes, or when a new leader takes over. A change while it ran queues it again once it's done, as after any reconcile. So the rollout that ships the fix reconciles it with the fixed code, without anyone touching the object. Until then, it panics once per resync, not once per backoff. FinalizersThe second bullet becomes:
TelemetryThe Logs and Metrics bullets become:
InstrumentsThis section is new.
The outcomes follow step 7 of Reconciling:
In Prometheus, they are Design decisionsThese are added:
TestingThe package's tests gain:
Out of scopeThese are added:
What the stories change outside the module gains:
Open questionsQuestions 1 to 4 are answered above. Question 5, a controller of a built-in kind, is still open. |
Revision 5: open question 5 answered, so a controller can reconcile a built-in kindA controller can reconcile one of Kubernetes' own kinds, such as a Namespace or a Secret. This is option (b) of revision 2. It is easy to add, since everything but the Reconciler is the same as for a custom resource. And it is an easy request to predict: an end user moving a controller of Pods or Namespaces off controller-runtime would ask for it first. Revision 2 left one choice to a revision: a second constructor and Reconciler, or a
All five open questions are now answered. Each section below replaces or adds to the part of the same name. The rest of the post, with revisions 1 to 4, stands. SummaryThis bullet is added after "A reconcile is handed its object and returns its status":
Proposed APIThese are added: // Kind names a kind whose Go type is defined elsewhere, such as one of
// Kubernetes' own, corev1.Namespace. Its objects decode into T. Its status
// belongs to whoever defines the kind, so a controller of a Kind applies no
// status.
type Kind[T any] struct {
GVR schema.GroupVersionResource // such as corev1.SchemeGroupVersion.WithResource("namespaces")
Kind string // such as Namespace
Scope Scope
}
// KindReconciler brings the cluster to what an object of a Kind declares.
type KindReconciler[T any] interface {
// Reconcile is handed obj, its own copy of the object as the cache last
// saw it. A positive after reconciles obj again after that long, unless
// it changes first. Zero waits for a change, or for the resync.
Reconcile(ctx context.Context, obj T) (after time.Duration, err error)
}
// KindReconcilerFunc is a function that is a KindReconciler.
type KindReconcilerFunc[T any] func(ctx context.Context, obj T) (time.Duration, error)
func (f KindReconcilerFunc[T]) Reconcile(ctx context.Context, obj T) (time.Duration, error)
// NewKindController returns a Controller of k's objects. It opens no
// connection. It returns one error naming every mistake in settings, in k
// and in parts. *T must be a metav1.Object, as every client-go type's is.
func NewKindController[T any](s *humus.Setup, c *Client, settings Settings, k Kind[T], rec KindReconciler[T], parts ...Part) (*Controller, error)These replace their namesakes: // Finalize keeps every object until f has cleaned up after it. The
// controller adds the finalizer name to each object it reconciles. Once an
// object is being deleted, it calls f in place of the Reconciler until f
// returns nil, and then removes name. T is the Controller's object type:
// Object[Spec, Status] for a Resource, and T for a Kind[T]. NewController
// and NewKindController check it.
func Finalize[T any](name string, f func(ctx context.Context, obj T) error) Part
// Controller reconciles the objects of one Resource or one Kind. It is a
// humus.Service.
type Controller struct{ /* unexported */ }
// NewController returns a Controller of r's objects. It opens no
// connection. It returns one error naming every mistake in settings, in r,
// as CRD reports them, and in parts.
func NewController[Spec, Status any](s *humus.Setup, c *Client, settings Settings, r Resource[Spec, Status], rec Reconciler[Spec, Status], parts ...Part) (*Controller, error)
func (c *Controller) Serve(ctx context.Context) error
func (c *Controller) Shutdown(ctx context.Context) error
// Synced is healthy once the controller's caches have synced, on every
// replica, leader or not.
func (c *Controller) Synced() health.MonitorA call such as KindsThis section is new, after "Resources, and their CRD". A
A cluster-scoped Kind or Resource can't be limited to a namespace. In useThis is added after the Memcached example. A controller gives every Namespace a NetworkPolicy that denies ingress, and puts it back when it's changed or deleted: var Namespaces = kube.Kind[corev1.Namespace]{
GVR: corev1.SchemeGroupVersion.WithResource("namespaces"),
Kind: "Namespace",
Scope: kube.Cluster,
}
type DefaultDeny struct{ Kube kubernetes.Interface }
func (r DefaultDeny) Reconcile(ctx context.Context, ns corev1.Namespace) (time.Duration, error) {
_, err := r.Kube.NetworkingV1().NetworkPolicies(ns.Name).Apply(ctx,
networkingv1ac.NetworkPolicy("default-deny", ns.Name).
WithOwnerReferences(metav1ac.OwnerReference().
WithAPIVersion("v1").WithKind("Namespace").WithName(ns.Name).WithUID(ns.UID).
WithController(true)).
WithSpec(networkingv1ac.NetworkPolicySpec().
WithPodSelector(metav1ac.LabelSelector()).
WithPolicyTypes(networkingv1.PolicyTypeIngress)),
metav1.ApplyOptions{FieldManager: "default-deny", Force: true},
)
return 0, err
}In the end user's Init: ctrl, err := kube.NewKindController(s, client, settings, Namespaces, DefaultDeny{Kube: kc},
kube.Owns(networkingv1.SchemeGroupVersion.WithResource("networkpolicies")),
)
TestingThe package's tests gain:
The container tests gain:
The prototypeThese weren't prototyped either: a controller of a built-in kind, and the checks of a Migration from todayThese rows are added:
Out of scopeThe RBAC that the docs list for a Controller gains, for a controller of a Kind:
Open questionsAll five are answered: questions 1 to 4 in revision 4, and question 5 above. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Note
Shutdown is amended by revision 1. The controller no longer drains its work queue. On shutdown it stops taking keys, finishes the reconciles in flight, and leaves the backlog for the next leader. The work queue's goroutine outlives
ShutDownWithDrainby up to 10s rather than leaking. The Proposed API doesn't change.Revision 2 adds a fifth open question: whether a controller can reconcile one of Kubernetes' own kinds, such as a Namespace or a Secret, rather than a custom resource.
Revision 3 prefixes the names humus defines with
humus.:kube.object.namebecomeshumus.kube.object.name, and open question 2's option (a) would put its instruments underhumus.kube.controller., withhumus.kube.controller.name.Revision 4 answers open questions 1 to 4. A panic in a reconcile or a finalizer is recovered and recorded, and never retried. The controller defines six instruments under
humus.kube.controller.. The end user fills theConnection, and tests a whole App against k3s, with nokubetest. Question 5 is still open.Revision 5 answers open question 5: a controller can reconcile a built-in kind, such as a Namespace.
kube.Kind[corev1.Namespace]names one,NewKindControllerreconciles it, and itsKindReconcilerreturns only when to reconcile again.ControllerandFinalizeloseSpecandStatus. All five open questions are answered.Note
A new feature for humus. It isn't part of the re-evaluation in Proposal: humus on the bedrock process model, which covers what humus had before the reset, so it has no wave. It builds only on accepted proposals: the lifecycle, configuration, OpenTelemetry without globals, humustest, health monitors and probes, humus.Main and exposing health probes. Admission and conversion webhooks need nothing from it: the OpenAPI endpoint DSL serves them.
Summary
kubeis a new nested module for writing Kubernetes controllers as humus Services. It is built on client-go's informers, work queue and leader election, and uses no code generation, no comment markers and no controller-runtime.kube.Resource[Spec, Status]names the resource, and its CRD is reflected from theSpecandStatustypes.kube.Field(&spec.Size).Minimum(1).CRD()returns one error naming every other mistake.kube.Object[Spec, Status]needs noDeepCopy, no scheme and noruntime.Object. The controller reads objects with client-go's dynamic client and decodes each into a fresh value, which is the end user's own copy.Reconcile(ctx, obj) (kube.Result[Status], error).Result.Afterreconciles it again later.Syncedis the readiness monitor.Host.kube.NewClient(s, kube.Connection{Server, TLS, Token})dials withs.Host.Dial, calls a rotating token's function at most once a minute, and traces every request withs.Telemetry.KUBERNETES_SERVICE_HOSTand no token file itself.Client.Config()gives client-go's own clientsets the same transport, for the resources a controller owns.synctest, and against k3s in a container. A prototype passes both. Against k3s v1.37.1:app.Initsources akube.Connectionandkube.Settings, builds the Client and the end user's Reconciler, and returns the Service thatkube.NewController(s, client, settings, app.Memcached, reconciler, kube.Owns(deployments))builds.Background
Why this proposal: controller-runtime
Nearly every Go controller is written with kubebuilder: controller-runtime at run time, and controller-gen for the code and YAML it generates from comment markers. This proposal exists because that stack can't be reconciled with humus, and because it is cumbersome to use. humus doesn't wrap controller-runtime, and doesn't depend on it.
It breaks humus's principles, some of them just by being imported. Every link is at v0.25.2, the latest release.
metrics.Registryis a package-level Prometheus registry.initfunctions fill it, and set client-go's global work queue, client and leader election metrics providers (workqueue.go#L102-L111, client_go_adapter.go#L164-L174, leaderelection.go#L40-L42). A program that importspkg/managerand does nothing else already has 4 metric families in the registry whenmainstarts (checked).log.Log, a global delegating logger. IfSetLoggerisn't called within 30s, it prints a warning and a stack trace toos.Stderr(log.go#L54-L72). Its cache logs through a package-levelRuntimeLog, whateverOptions.Loggersays.initfunction adds--kubeconfigtoflag.CommandLine(config.go#L43-L45), so importing the config package adds the flag (checked).GetConfigreadsKUBECONFIGwithos.Getenvand the home directory withuser.Current(#L123, #L147).SetupSignalHandlercallssignal.Notify, callsos.Exit(1)on a second signal, and panics if it is called twice (signal.go#L25-L41).net.Listen(manager.go#L474-L479). The metrics server binds:8080by default (server.go#L45), a port humus would never impose.manager.Optionshas 28 exported fields (manager.go#L107-L295). A Manager is the cache, the clients, the scheme, leader election, and the metrics, health, pprof and webhook servers, all in one: the large type that tries to anticipate every use.kube-apiserverandetcdbinaries withexec.Command, and finds them throughKUBEBUILDER_ASSETS(process.go#L158, server.go#L49). Its fake client's own doc says "it's almost always better not to use this package" (fake/doc.go#L27).Its telemetry is Prometheus only. The book tells end users to register custom metrics "with the global prometheus registry" (metrics). OpenTelemetry support is still an open issue (#305).
Its developer experience is cumbersome.
// +kubebuilder:validation:Minimun=1produces a CRD with no minimum, and the run exits 0 with nothing on stderr (checked).Minimum=one, is an error.make generateto create the DeepCopy implementations… Then, runmake manifeststo generate the CRD manifests" (getting started).kubebuilder initand onecreate apiscaffold 54 files in 20 directories (counted).reconcile.Request"does NOT contain information about any specific Event or the object contents itself" (reconcile.go#L62-L68).Result.Requeue,Result.RequeueAfterand a returned error all requeue, and an error makes the Result ignored (reconcile.go#L117-L127).Requeuebecause "it causes confusion" (reconcile.go#L31-L42, #3107).Client.Cache.Unstructuredis set (client.go#L92-L95).Wrapping controller-runtime in humus's types would keep all of this. Its globals are set by
initfunctions, so no wrapper can avoid them.Why not the
openapipackageThe OpenAPI endpoint DSL (#760) was the first candidate. It serves the webhooks, and nothing else a controller needs:
A controller is a client of the API server, and
openapiis a server.openapihas a place for that loop.openapi, withjsoncodec, serves.openapi.SchemaOfwrites the wrong dialect. It writes OpenAPI 3.2's JSON Schema 2020-12, in which a pointer field istype: ["null", …]. A CRD takes only OpenAPI 3.0's Schema Object, restricted to a structural subset:typeis one string, so a pointer isnullable: true;exclusiveMinimumis a bool;$refanddefinitionsare forbidden, so every schema is inlined;x-kubernetes-*extensions mark int-or-string values, list and map types, and CEL validations.JSONSchemaProps.Typeis a Gostring(types_jsonschema.go#L45). k3s v1.37.1 rejects a CRD with a type array (checked): "json: cannot unmarshal array into Go struct field JSONSchemaProps…type of type string".The format won't change. Kubernetes closed "OpenAPI 3.1 Support" as not planned on 2026-10-17 (kubernetes#134684).
kube-rs hit the same wall (kube#264). It runs schemars in OpenAPI 3.0 mode with every subschema inlined, then rewrites the output in three passes to make it structural (schema.rs).
Why not Metacontroller
Metacontroller v4.17.6 (2026-10-15) does the watching and applying itself. It calls a controller's sync and finalize hooks over HTTP, so
openapicould serve a whole controller. But it has three costs:That is a different product, which an end user can adopt today without anything from humus.
What client-go gives, and where it leaks
client-go v0.37.1 is Kubernetes' own client, released with each Kubernetes release. This proposal uses five of its parts directly:
DeepCopy.RunWithContexttakes its logger from the context (shared_informer.go#L841).rest.Config, which takes aTransportof the caller's own (config.go#L108), so every connection can go through theHost.It reaches around the process in six places, which this proposal avoids or documents:
rest.InClusterConfigreadsKUBERNETES_SERVICE_HOSTandKUBERNETES_SERVICE_PORTwithos.Getenv, and the token from a hard-coded path withos.ReadFile(config.go#L546-L577). humus never calls it: the end user's Init sources the Connection.BearerTokenFileis read again withos.ReadFileevery minute (token_source.go#L69-L81, #L135). The Connection'sTokenfunction replaces it.Proxyset, client-go useshttp.ProxyFromEnvironment(cache.go#L142). The Client's transport has no proxy.DISABLE_HTTP2,HTTP2_READ_IDLE_TIMEOUT_SECONDSandHTTP2_PING_TIMEOUT_SECONDSwithos.Getenv(http.go#L134-L170). The Client builds its own transport, with the same 30s and 15s.KUBE_FEATURE_*withos.LookupEnvon first use, and log throughklog.Background()(envvar.go#L165-L169). The only replacement isfeatures.ReplaceFeatureGates, which is global. This one stays an ambient leak.Proposed API
The module's dependencies are client-go, apimachinery and api v0.37.1, apiextensions-apiserver v0.37.1 for the CRD's types, and klog and logr, which client-go already brings. testcontainers' k3s module is used only behind the build tag.
Connecting
NewClientbuilds anet/httptransport of its own, and hands client-go arest.ConfigwhoseTransportis it:s.Host.Dial, and runs TLS with a clone ofConnection.TLS, whoseServerNamedefaults toServer's host.http.ProxyFromEnvironment, would read the real environment.Tokenat most once a minute, as client-go re-reads a token file. When a call fails, it keeps the last token, which a rotated service account token outlives.otelhttpwiths.Telemetry's providers and propagator.So every client-go client built from
Config()reaches the API server the same way: the dynamic and metadata clients the Controller uses, and the clientset of Kubernetes' own resources the end user's Reconciler applies its children with.Settings
Connection.Serverhost:port, port 1 to 65535CheckServerConnection.TLSCheckTLSConnection.Tokennil, no tokenNameCheckNameNamespace"", every namespace""or a DNS-1123 labelCheckWatchNamespaceWorkers1CheckWorkersResync10hCheckResyncRetryBaseDelay5msCheckRetryDelayRetryMaxDelay1000sRetryBaseDelayCheckRetryDelay, andNewControllerfor the boundLeaderElection.NamespaceCheckNamespaceLeaderElection.IdentityCheckIdentityLeaderElection.LeaseDuration15sRenewDeadlineNewControllerLeaderElection.RenewDeadline10sRetryPeriodsNewControllerLeaderElection.RetryPeriod2sCheckRetryPeriodServer,TLS,Nameand the Lease'sNamespaceandIdentityare required. humus imposes no API server, no name and no namespace, as it imposes no port.httpserverandqueue/rabbitmqdon't. In a pod, the end user's Init sourcesServerfromKUBERNETES_SERVICE_HOSTandKUBERNETES_SERVICE_PORT, which the kubelet sets. It reads the CA and the token from/var/run/secrets/kubernetes.io/serviceaccount/throughs.Host.FS, and takes the Lease'sNamespaceandIdentityfrom the downward API (see In use).DefaultTypedControllerRateLimiter(default_rate_limiters.go#L50-L56), whose overall limit of 10 retries a second, bursting to 100, the controller keeps as well. The Lease's durations are kube-controller-manager's defaults.Resources, and their CRD
CRD()reflectsSpecandStatusinto a structural schema:booltype: booleanint8toint32,uint8,uint16type: integer,format: int32int,int64,uint,uint32,uint64type: integer,format: int64float32,float64type: numberstringtype: string[]bytetype: string,format: bytetype: array, withitemsmap[string]Vtype: object, withadditionalPropertiestype: object, with a property for each exported field, named by itsjsontag; an embedded struct is inlined, asencoding/jsondoes*TT's schema, withnullable: trueunless the field isomitemptyoromitzerometav1.Time,metav1.MicroTimetype: string,format: date-timemetav1.Durationtype: stringresource.Quantity,intstr.IntOrStringx-kubernetes-int-or-string,anyOfinteger and stringruntime.RawExtensiontype: object,x-kubernetes-preserve-unknown-fieldsmetav1.ObjectMeta, in a templatetype: objectjsontag hasomitemptyoromitzero, or it has aDefault. The encoding decides it: a field the encoder always writes is always there.Rules[T]constrainsT's own fields, those promoted from embedded structs included, whereverTappears.&spec.Template.Port, is constrained by that struct's ownRules. A rule on it in the outer type'sRulesis an error.CRD()returns one error naming every mistake it finds:Group,Version,KindorPlural, or aScopethat is neither;json.Marshalerand has noSchema, since its fields aren't its encoding;$refcan't express;Rulesfor one type.kube.Field(&spec.Size).Minimun(1): "kube.FieldOf[int32] has no field or method Minimun".kube.Field(&spec.Size).Minimum("one"): "cannot use "one" (untyped string constant) as int32 value".kube.Field(&spec.Image).Default(3): "cannot use 3 (untyped int constant) as string value".apiVersion,kindandmetadata, a requiredspec, andstatus. It is named<Plural>.<Group>, and has one version, served and stored, with the status subresource.CRD()doesn't compile CEL. The API server compiles every rule when the CRD is applied, which the end user's container tests do (see Testing).What
ServedoesServe, naming the resource, so the App exits 1 (checked). Without this check, an informer's reflector would retry the list forever, and only log.Owns, through the metadata client, since an owned resource is only looked at for its owner references. All of them are limited toSettings.Namespace, and run with a logger in their context.Syncedhealthy.Workersworkers.Reconciling
For each key:
Object. The decode is the copy, so nothing the Reconciler does changes the cache.Finalizeis given (see Finalizers).Finalizeisn't given.Reconcile. Its context carries the reconcile's span, and is cancelled only whenShutdown's context is done or the Lease is lost.Result.Statusisn't nil and differs from the object's, compared by their JSON encodings, the controller applies it to the status subresource with server-side apply. The field manager isSettings.Name, and it forces, since the controller owns the status whole. This happens whether or notReconcilereturned an error.RetryBaseDelay, doubling toRetryMaxDelay. The error is recorded, and the Service goes on.Afterreconciles it again after that long, unless it changes first.Applying a status changes the object, so the controller reconciles it once more. That reconcile returns the same status, which isn't applied, and the object settles: two reconciles in all (checked).
Finalizers
Finalize(name, f):metadata.finalizers: [name]with server-side apply, under its field manager.f, and not the Reconciler. An error retries, as a reconcile's does.freturns nil. The controller applies the object's metadata without the finalizer. The field manager that set it is the only one that owns it, so the API server removes it, and the deletion completes (checked).Finalizeis generic inf, and aPartisn't, soNewControllerchecks thatftakes anObjectof the Controller's types.Owned resources
Owns(gvr)watchesgvr's resources through a metadata informer. A change to one whose controller owner reference names the Resource's group and kind reconciles that owner. In the prototype, scaling an owned Deployment by hand was undone within the second (checked).The end user sets the owner reference when they apply the child, with client-go's apply configurations (see In use).
Leader election
ReleaseOnCancel's doc requires: "you must ensure all code guarded by this lease has successfully completed prior to cancelling the context, or you may have two processes simultaneously acting on the critical path" (leaderelection.go#L153-L157). So the next replica of a rollout takes over without waiting outLeaseDuration(checked: the Lease has no holder after exit 0).RenewDeadline, for example while the API server is unreachable, or another replica can take the Lease. Then the controller cancels the reconciles in flight, andServereturnsErrLostLeadership. The App exits 1, and Kubernetes restarts the pod, which campaigns again.Shutdown
Serve's context, the controller stops taking keys, lets the reconciles in flight finish, releases the Lease, stops its informers, and returns nil. The App exits 0 (checked).Shutdown's context is done first, the controller cancels the reconciles in flight, waits for them to return, and returns an error that wraps the context's error.humus.Main's defaults fit a controller, as they fit a consumer: a 20s grace period and no drain. So there is nokube.Main.Readiness
Synced()is unhealthy until the controller's caches have synced, and healthy from then untilServereturns. That holds on every replica, leader or not, so a rollout isn't held up by the replicas waiting for the Lease. Likerabbitmq'sConsuming, it is for readiness only. The end user serves it with exposing health probes, ashealth.Readiness(ctrl.Synced()).Telemetry
Under the scope
github.com/z5labs/humus/kube, froms.Telemetry:reconcile <Kind>, withk8s.namespace.nameandkube.object.name. An error sets its status to Error.otelhttp, except watches, which last as long as an informer runs.reconcile failed, in the span's context, with the object and the error.otelhttp's client instruments, for every API request. The work queue's own are open question 2.In use
The kubebuilder book's Memcached operator, in full apart from its settings and its probes. The types, with no markers and no generated code:
The Reconciler applies a Deployment that the Memcached controls, and reports its ready replicas:
Compared with the book's controller, there is no
Getand no not-found, noStatus().Updateand no fetching again, and no conflict to retry. A failure is a condition and an error, together.The end user's Init:
In a pod,
readSettingssources the Connection from what the kubelet provides:readSettingssources thekube.Settingsasqueue/rabbitmq's docs show, with the Lease'sNamespaceandIdentityfromPOD_NAMESPACEandPOD_NAME, which the pod's spec sets from the downward API.The CRD in the end user's repo. Whatever deploys the controller applies the CRD's YAML, such as a Helm chart or a kustomization. A golden test keeps that YAML in step with the Go types:
A change to the types that isn't in the YAML fails
go test, in place of amake manifeststhat someone forgot to run.Design decisions
No controller-runtime, and no controller-gen, anywhere. Background says why. depguard denies both in every humus module, so neither can come back as a convenience.
client-go's parts, without generated clientsets.
Objectremoves the scheme,runtime.ObjectandDeepCopy, which are why kubebuilder generates code at all.The schema is reflected from the Go types, and constraints are typed values on fields. Four other ways were weighed:
kube:"minimum=1": a mini-language in strings, which nothing checks beforeCRD(), and whose values have no type;JSONSchemaProps: a second copy of every type, which drifts from the first;kube.Field("size"): a typo is found byCRD()rather than the compiler, and a constraint's value has no type.A field pointer is resolved by its offset in the value given to
Rules, and checked against the field's type. The compiler checks the field's name, the constraint's name and the value's type.The cost: a description is a value, not the field's doc comment, so
kubectl explainshows only whatDescriptiongives.Rulesalso needs a value of the type to point into, usually a package-level variable. A closure,kube.Rules(func(s *MemcachedSpec) []kube.FieldRule {…}), would need no variable, and reads worse.Rules belong to a type. A type such as a port or a template is constrained once, and constrained alike wherever it appears.
One version per Resource. Several versions need conversion between them, which is a webhook or a promise that the schemas match (see Out of scope).
The controller never installs its CRD.
Serveloudly, rather than leaving a controller that waits forever.A reconcile is handed its object. The cache already has it, and the decode copies it. A name would make every Reconcile fetch it and handle not-found, as controller-runtime's do.
A reconcile returns its status, and the controller applies it.
return kube.Result[S]{}, errcan't wipe the conditions.kubectl getshows.One way to reconcile again for each reason: an error backs off,
Afterwaits, and nothing waits for a change. There is nothing to deprecate.A reconcile error never ends the Service, unlike
queue's contract. A failed message comes back when the App restarts, so ending the Service is howqueueretries. A controller is level-triggered: its queue retries the object, and a restart would only reconcile the same object again, after a crash loop's back-off.Finalizers are applied, not updated. Adding and removing one is an apply under the controller's field manager, so it never conflicts with other writers of the metadata.
Leader election is always on. Apart from the Lease's RBAC, it costs one replica nothing. And two replicas without it would apply the same objects twice.
Losing the Lease ends the App. Another replica may already be reconciling. client-go's own example exits when it stops leading (main.go#L150-L165), and the pod campaigns again when it restarts.
Every replica keeps its caches warm. A new leader starts reconciling at once, and
Syncedcan be every replica's readiness.The client has no rate limit of its own. client-go's default of 5 requests a second throttles a busy controller. The API server's priority and fairness protects it, and limits each client by its identity.
The Client has its own transport. It is what dials through the
Host, and it keeps client-go's environment variables out.Webhooks stay with
openapi. An admission webhook is an HTTPS POST of JSON, whichopenapiserves. Note that a denial is a 200 withallowed: false, since any other status is a failure under the webhook'sfailurePolicy. CEL policies remove most webhooks anyway: ValidatingAdmissionPolicy is stable since v1.30, and MutatingAdmissionPolicy since v1.36.The package is
kube, notkubernetes. client-go's clientset package is namedkubernetes, and the end user imports it alongside.A nested module. client-go and apimachinery are large, and only a controller should bring them into a module graph.
Testing
The package's tests
synctest, on client-go's own fakes.queue/rabbitmq'sSetOpensets its channel.Hostare unit tested on humustest'sHost, with a plain HTTP handler that records what it receives.rancher/k3s:v1.37.1-k3s1, started asserver --disable-agent. That is the API server and the controller manager without a kubelet: no pod runs, but the garbage collector does.testcontainersbuild tag, in a CI job of their own that isn't required, outside the coverage gate.Hostunless humus writes one, and humus doesn't write other projects' servers.What the end user tests
Object, so a test builds one. Its children are applied throughkubefake.NewClientset(), whose field-managed tracker applies typed objects with their managed fields (checked).humus.OSHost()and humustest's process, as RabbitMQ's container tests run.The prototype
A prototype was built in a scratch module, against:
d1cfa0bf;rancher/k3s:v1.37.1-k3s1.It is about 1,000 lines: 343 for the schema, 100 for
ResourceandCRD, 120 for theClientand 461 for theController. It passesgo test -race, the unit test with-count=5. Against k3s, it shows:The reflected CRD is accepted, with
fieldValidation=Strict. The API server enforces its rules:It also defaults
imagewhen it is left out.A type array is rejected, as
openapi.SchemaOfwould write a pointer.A whole App, on
humus.OSHost():memcached-controllerApplyandApplyonstatus.A missing CRD ends the App with exit 1: "kube: listing cache.example.com/v1alpha1, Resource=absents: the server could not find the requested resource".
Inside
synctest, on client-go's fakes, the informers, the work queue and leader election run through a minute of fake time:Afterreconciles it again;It also found four things this proposal now accounts for:
ShutDownWithDraindoesn't stop the delaying queue's goroutine; onlyShutDowndoes (delaying_queue.go#L240, queue.go#L327). SoServecallsShutDownafter draining. synctest's leak check caught it, andhumustest.TestService's will.Resultneeds to be a pointer. With a value, an error path that returns a zeroResultwould wipe the object's status.These weren't prototyped:
Shutdown's deadline;TypeRules.Validation,Schema,ListSet,MinItems,MaxItems,MinLengthandMaxLength;Migration from today
humus has never had Kubernetes support, so nothing in humus changes. An end user moving from kubebuilder maps its pieces like this:
kubekubebuilder init,create api: 54 files in 20 directories// +kubebuilder:validation:Minimum=1and other markerskube.Field(&spec.Size).Minimum(1), inResource.Rules.Description(…)// +kubebuilder:object:root=true,zz_generated.deepcopy.go,make generatekube.Object[Spec, Status]make manifestsResource.CRD(), and a golden testAddToScheme, and a scheme inmainReconcile(ctx, req), thenr.GetandIsNotFoundReconcile(ctx, obj)r.Status().Update(ctx, obj), thenr.Getagainkube.Result{Status: &st}Result{Requeue: true},RequeueAfter, a returned errorResult.Aftercontrollerutil.AddFinalizer,r.Update,RemoveFinalizerkube.Finalize(name, f)Owns(&appsv1.Deployment{})kube.Owns(appsv1.SchemeGroupVersion.WithResource("deployments"))ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{…})kube.NewClientandkube.NewController, in Initctrl.SetupSignalHandler(),mgr.Starthumus.MainLeaderElection: true,LeaderElectionIDSettings.NameHealthProbeBindAddress,AddReadyzCheckctrl.Synced(), served with health probesmetrics.Registry.MustRegisterMeterProviderins.Telemetrylog.SetLogger(zap.New(…))LoggerProviderins.Telemetry// +kubebuilder:rbac:…// +kubebuilder:webhook:…, cert-manageropenapiAPI onhttpserverOpen questions
A panic in
Reconcileor a finalizer.openapiandgrpcserverrecord a handler's: an exception event with the stack, the span's status set to Error, and one ERROR record,reconcile panicked. Then it retries the object with backoff. Like a request, a reconcile is about one object, and one object's bug doesn't stop the others.queuedoes. The crash loop shows the bug. But it stops reconciling every object, and a restart doesn't help a level-triggered loop: the same object panics again after every restart.Recommendation: (a).
Instruments of the controller's own. OpenTelemetry has no conventions for controllers.
kube.controller., withkube.controller.name:MetricsProviderof humus's own for each queue;otelhttp's request instruments, are all there is.Recommendation: (a).
Local development against a cluster.
clientcmd.Load.kube.FromKubeconfig(fsys, path, context) (Connection, error). It would read the files a kubeconfig names through the FS, and reject exec plugins and auth providers. humus would then own a credential file's semantics, which grow with every cloud.Recommendation: (a).
The end user's whole-App tests.
kubetestpackage that builds a Client on client-go's fakes, so a whole App runs insidesynctest. But the fakes can't apply an unstructured object (checked), and have no admission, validation, defaulting or garbage collection. So a test passes where the cluster would reject.Recommendation: (a).
Out of scope
Admission and conversion webhooks, which an
openapiAPI serves onhttpserver, and CEL admission policies, which need no server.Several versions of one resource, and conversion between them.
CRD details: printer columns, short names, categories, and the scale subresource.
Mapping resources the controller doesn't own to its objects, as controller-runtime's
Watchesdoes with a map function.Ownscovers owned resources, andResult.Afterpolls the rest.Generating RBAC. The docs list the rules a Controller needs itself:
The end user adds their Reconciler's own.
Installing CRDs.
Exec credential plugins and auth providers.
Tokencovers a cloud's tokens, from the cloud's own SDK in the end user's code.Typed clientsets, listers and informers for a custom resource, which client-gen generates.
Aggregated API servers, and Metacontroller.
Compiling CEL in
CRD().What the stories change outside the module:
.github/workflows/build.yamlgets a "Kubernetes (testcontainers)" job, which isn't a required check, and a step that vets the tagged tests..github/scripts/go-modules.shfinds the new module..golangci.yamlenables depguard, and deniessigs.k8s.io/controller-runtimeandsigs.k8s.io/controller-toolsin every module. forbidigo gains:rest.InClusterConfig, which reads the real environment and disk;SetLogger,SetLoggerWithOptions,SetOutputandInitFlags;features.ReplaceFeatureGates, which is global.kube.References
queue's contract, Proposal: RabbitMQAll reactions