README
¶
libseanfarm-operator
This is a shared Go toolkit for the seanfarm Kubebuilder operators: forgejo-operator, kratos-identity-operator, openbao-operator, and minio-resource-operator.
Delivery model
Forgejo is the canonical source. Pull requests target trunk. A Release
request selects green Changes, integrates them in pull-request order, and
uses Forgejo rebase merges to serialize them. It then tests the combined
trunk revision. A successful Release moves main and
creates the immutable library tag. This library has no Deployment target.
Binjovi is the delivery authority. The retained Forgejo policy requires the
binjovi/ci result for the exact pull-request head. Binjovi confirms this
provider policy before it changes trunk, main, or a tag.
A failed head remains immutable evidence. A retry requires a new head.
Each operator reconciles resources in an external system. Forgejo reconciles repos and orgs. Kratos reconciles Ory Kratos identities. OpenBao reconciles OpenBao/Vault config. MinIO reconciles MinIO buckets and IAM. Each operator does this declaratively, from Kubernetes CRs.
Each operator used to carry a near-identical, hand-written reconcile loop. The four loops drifted apart over time. This library moves that loop, and the conventions around it, into one place. Now the four operators share one auditable implementation instead of four copies that diverge.
import "codeberg.org/someara/libseanfarm-operator/reconcile"
The generic harness
reconcile.Run[T, C] owns the per-reconcile lifecycle, from start
to end:
deletion (policy-aware) → finalizer → dry-run → client resolution →
Converge → conditions/status write → requeue
A controller's Reconcile method does one thing: it fetches the typed
object and handles NotFound. It then hands the object to Run. The
operator supplies only a thin policy shim:
- a
Handler[T, C](ConvergeandDelete, plusFinalizerName) - a
Taxonomythat classifies each failure reason asTerminal,FixedRequeue, orBackoff
Converge returns an Outcome. It returns Converged(...) only
after it verifies the external state is correct. Otherwise it
returns Progressing(...). The harness sets Ready, never the
handler, and only on a Converged outcome. This makes "Ready means
verified" structural. A handler cannot publish Ready=true while
work is still in flight. This structural rule guards against the
premature-Ready bug class.
type fooHandler struct{}
func (fooHandler) FinalizerName() string { return "foo.example.com/finalizer" }
func (fooHandler) Converge(ctx context.Context, obj *v1alpha1.Foo, c *Client) (reconcile.Outcome, error) {
if err := c.Apply(ctx, obj.Spec); err != nil {
return reconcile.Outcome{}, reconcile.Failf("ApplyFailed", "apply foo: %v", err)
}
if !c.Observed(ctx, obj.Spec) { // write not yet readable
return reconcile.Progressing("Applying", "waiting for foo to settle"), nil
}
return reconcile.Converged("Verified", "foo reconciled"), nil
}
func (fooHandler) Delete(ctx context.Context, obj *v1alpha1.Foo, c *Client) error {
return client.IgnoreNotFound(c.Remove(ctx, obj.Spec)) // NotFound == success
}
cfg := reconcile.Config[*v1alpha1.Foo, *Client]{
Client: mgr.GetClient(),
Resolve: resolveFooClient,
Taxonomy: fooTaxonomy,
Recorder: recorder,
}
return reconcile.Run(ctx, cfg, obj, fooHandler{})
Config knobs
Build one Config[T, C] per operator. Build it at package level.
Share it across that operator's controllers. Only the Handler
varies per type. The table below lists the knobs. Each knob marks a
place where the four old copies had silently diverged. Now each
choice is explicit and reviewable:
| Field | Role |
|---|---|
Resolve / ResolveFailureReason |
Builds the operator's API client C for an object. It builds the client from refs, from Secrets, or from a factory. A resolution failure flows through the Taxonomy, the same as a Converge error. If the Taxonomy has no match, the harness falls back to ResolveFailureReason. |
Taxonomy |
Maps each reason string to Terminal, FixedRequeue, or Backoff. This table replaces the old hand-rolled classify*Error ladders. An unmapped reason takes the Default disposition. |
DeleteTaxonomy |
Optional. Overrides the disposition for a reason on the delete path only. Use this when a reason needs a different disposition on delete than on converge. Example: MinIO's BucketNotEmpty is Terminal on converge. On delete, the harness holds and re-checks instead. |
Cadences |
Sets the requeue interval for each phase. Ready is the steady-state drift-resync interval. Progressing is the in-progress interval. Transient is the fixed-requeue and delete-retry interval. A zero field takes its documented default. |
Conditions (ConditionStyle) |
Chooses which conditions to maintain beyond Ready: EmitProgressing and EmitDegraded. The harness always sets Ready. Ready is the only condition external consumers read. |
Deletion (DeleteSemantics) |
Chooses the deletion philosophy. HoldUntilSuccess keeps the finalizer until the remote delete succeeds. The harness classifies a failure by the Taxonomy. BestEffortRelease tries the remote delete once, then releases the finalizer regardless. BestEffortRelease never lets a flaky remote block deletion. |
RequeueOnFinalizerAdd |
When true, the harness ends the pass after it adds the finalizer. The update event then re-triggers the next pass. When false, the harness continues in the same pass. |
DryRunAnnotation |
Names an annotation key. When the object carries that annotation set to "true", the harness sets Ready=True with reason DryRun. The harness does not touch the remote system in this case. |
ObservedGen (ObservedGenPolicy) |
Chooses when the harness stamps ObservedGeneration. ObservedGenOnAllSuccess stamps it on any non-error converge, including a Progressing outcome. ObservedGenOnConvergedOnly stamps it only on full convergence. Use ObservedGenOnConvergedOnly when a handler runs a generation-skip fast path. |
Recorder |
When set, the harness emits a Warning event on a converge or delete error. The event message is size-bounded. The event message is not sanitized. |
StampSuccess / MirrorStatus / OnStatusWrite |
Three optional hooks. StampSuccess stamps a last-reconciled timestamp. MirrorStatus mirrors legacy phase and ready fields, and gauges, before each status write. OnStatusWrite observes the result of every status write. |
Two optional interfaces add behavior. Neither one changes the
Handler signature:
CleanupHandler[T]— When a handler implements this interface,RuncallsCleanupon every delete path. This includes theDeletionPolicy=Retainpath.RuncallsCleanupbefore it removes the finalizer. This way,Retainnever leaks operator-minted Kubernetes-side state, such as credential Secrets.DeletionPolicyProvider— Implement this interface on the object, not the handler. It returns the effectiveRetainorDeletepolicy. Each type encodes its own empty-value default in the accessor. An object that does not implement this interface is always treated asDelete.
Supporting primitives
The harness is built from small pieces. Each piece works on its own:
| Primitive | Purpose |
|---|---|
Outcome, Converged(), Progressing(), .After() |
What Converge reports back to the harness. The harness turns this report into the Ready condition. A Progressing report is what keeps Ready from going true too early. |
ReasonedError, Failf, FailfRequeue, ReasonOf, RequeueTransientOf |
Attach a condition reason to an error without touching status. FailfRequeue keeps a normally-latching reason visible on status. At the same time, it lets the harness self-heal on the transient cadence. The extractor functions walk a wrapped error chain with errors.As. |
ConditionReady/Progressing/Degraded, ReadyTrue, ReadyFalse, SetCondition, MarkDegraded, StatusObject |
The shared condition vocabulary and builder functions. Only Ready is consumed externally, for example by Flux healthChecks and claim readiness checks. Every message is bounded to 512 bytes (MaxConditionMessageBytes). The bound always cuts on a UTF-8 rune boundary. |
PersistStatus, StatusUpdateBestEffort |
Conflict-safe status writes. On a conflict, these functions refresh only resourceVersion and keep the caller-populated status as is. Type-specific status fields survive a retry this way. A remote write stays single-shot. |
EnsureFinalizer, RemoveFinalizer, ShouldProcessDeletion |
Conflict-retried finalizer mechanics. These functions never write through the caller's slice. |
MapSecretToRequests |
A generic Secret-watch mapper. It re-enqueues each CR whose referenced Secret name matches a Secret event. Wire it into Watches(...). This way, a Secret that lands or rotates re-triggers reconcile immediately. Without it, the controller waits out a cached "not found" instead. |
SanitizingRecorder, NewSanitizingRecorder, EmitNormal, EmitWarning |
Event emission helpers. Every message is bounded by the same 512-byte cap. This bound applies even at Event sites in controllers or vendored packages that call the recorder directly. |
ControllerTuning, ControllerOptions |
Uniform concurrency control (MaxConcurrentReconciles) and a typed exponential-failure rate limiter with a base delay and a max delay. Wire this into SetupWithManager through .WithOptions. This way, all four operators back off consistently. No operator needs to hardcode its own rate limiter. |
Rundrives the per-reconcile lifecycle.MapSecretToRequestsandControllerOptionsare package helpers. Wire both at manager setup, throughWatchesor.WithOptions. Neither one runs insideRun.
Why a shared harness
This library does more than remove duplicate code. It turns four
loops that had quietly disagreed with each other into one set of
explicit, auditable knobs. Here are the divergences it resolved.
Each one is now a named Config choice:
TransientError: requeue or error. OpenBao mapped a transient error to a fixed 30-second requeue. MinIO let the same-named reason fall through to workqueue backoff instead. The two operators used opposite policies under the same name. Now each policy is aTaxonomyentry you can read at a glance.ObservedGeneration: when to stamp it. Some operators stamped observed generation on every successful pass, including aProgressingpass. One operator had to stamp it only on full convergence. Otherwise, a generation-skip fast path would short-circuit its progressing loop. This choice is nowObservedGenPolicy.- Finalizer-add robustness. The harness retries the finalizer add
on a conflict. An operator can choose to re-trigger through the
update event with
RequeueOnFinalizerAdd. Otherwise the harness continues in the same pass. - Deletion semantics.
HoldUntilSuccesskeeps the finalizer until the remote delete is confirmed. The harness classifies a failure by theTaxonomy.BestEffortReleasereleases the finalizer regardless, so deletion never wedges on a flaky remote. This choice used to be an implicit per-operator habit. Now it isDeleteSemantics.
forgejo-operator is the origin of this design. Every later operator
copy was written by mirroring forgejo-operator. This library
generalizes that one loop back across all four operators.
Security contract
Condition reasons, condition messages, and Events land in world-readable status surfaces. These surfaces are far more readable than the Secrets they may describe. The rule: name the Secret, never quote it. No plaintext DB password, GCP service-account key material, token, or credentialed DSN may reach status, Events, or logs.
- Never put secret material into a
Failf,FailfRequeue,Converged, orProgressingreason or message, and never put it into Event text. At a credential-bearing site, emit a generic message instead. - The library bounds every condition and Event message it writes to
512 bytes (
MaxConditionMessageBytes). This bound serves two purposes. First, availability: a splatted remote error body cannot blow etcd's per-object size limit. Second, confidentiality: the bound caps how much a stray raw error can disclose. This bound limits disclosure. It does not sanitize the message. The real fix for a credential-bearing path is a generic message at the source. SanitizingRecorderapplies the same 512-byte bound to Event sites that bypass the library's helper functions. Wrap the manager's recorder once, at wiring time.
Versioning & distribution
- Tags are immutable. The module proxy caches each tag forever.
The cluster's Shipwright builds fetch through the default
GOPROXY. Never re-point a tag. - This library follows pre-1.0 semver. A breaking API change bumps
the minor version. Each consumer pins its own version in
go.mod. Operators do not need to upgrade in lockstep.
Repo layout / where to push
The canonical day-to-day remote is the in-cluster Forgejo, at
code.sean.farm/.../libseanfarm-operator. A ForgejoPushMirror backs
it up to codeberg.org/someara/libseanfarm-operator roughly every 10
minutes. make rebuild re-seeds Forgejo from codeberg. A Go module
fetch reads codeberg through the default GOPROXY. A new tag is
therefore not visible to in-cluster builds until the push-mirror has
run. You can also push the tag to codeberg directly to skip the wait.
Operator container images are built in-cluster, by Shipwright
using the buildkit strategy, and are cosign-signed. Never build an
image on a laptop. CI runs in-cluster, through Argo Workflows and
Tekton. There are no GitHub Actions and no Forgejo Actions. There must
never be a .github directory or a .forgejo/workflows directory in
this repo.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package httpkit is the shared HTTP client plumbing that each seanfarm operator used to hand-roll: cluster CA-trust transports, credential-rotation-aware client caches, and bounded response reads with condition-safe error excerpts.
|
Package httpkit is the shared HTTP client plumbing that each seanfarm operator used to hand-roll: cluster CA-trust transports, credential-rotation-aware client caches, and bounded response reads with condition-safe error excerpts. |
|
Package reconcile is the shared reconcile toolkit for the seanfarm Kubebuilder operators: forgejo, kratos, openbao, and minio.
|
Package reconcile is the shared reconcile toolkit for the seanfarm Kubebuilder operators: forgejo, kratos, openbao, and minio. |
|
testkit
Package testkit is the shared test surface for libseanfarm-operator consumers.
|
Package testkit is the shared test surface for libseanfarm-operator consumers. |