- Go 94.8%
- Makefile 4.3%
- Dockerfile 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| httpkit | ||
| reconcile | ||
| .gitattributes | ||
| .gitignore | ||
| CHANGELOG.md | ||
| ci.Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| operator.mk | ||
| README.md | ||
| VERSION | ||
libseanfarm-operator
Shared Go toolkit for the seanfarm Kubebuilder operators (forgejo-operator, kratos-identity-operator, openbao-operator, minio-resource-operator). Each of those operators reconciles resources in an external system (Forgejo repos/orgs, Ory Kratos identities, OpenBao/Vault config, MinIO buckets + IAM) declaratively from Kubernetes CRs. They all carried a near-identical, hand-written reconcile loop that drifted apart over time. This library hoists that loop — and the conventions around it — into one place so the four operators share a single, auditable implementation instead of four diverging copies.
import "codeberg.org/someara/libseanfarm-operator/reconcile"
The generic harness
reconcile.Run[T, C] owns the per-reconcile lifecycle, end to end:
deletion (policy-aware) → finalizer → dry-run → client resolution →
Converge → conditions/status write → requeue
A controller's Reconcile does only the typed fetch (handling NotFound)
and hands the object to Run. The operator supplies a thin policy shim —
nothing more:
- a
Handler[T, C](Converge+Delete, plusFinalizerName), and - a
Taxonomythat classifies each failure reason asTerminal | FixedRequeue | Backoff.
Converge returns an Outcome — Converged(...) only after it has
verified the external state is observably correct, otherwise
Progressing(...). The harness, never the handler, sets Ready, and only
on a Converged outcome. "Ready means verified" is therefore structural: a
handler cannot accidentally publish Ready=true while work is still in
flight. This is the structural guard 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
One Config[T, C] per operator (build it package-level and share it across
that operator's controllers; only the Handler varies per type). The knobs
are exactly the places the four copies had silently diverged — now explicit
and reviewable:
| Field | Role |
|---|---|
Resolve / ResolveFailureReason |
Builds the operator's API client C for an object (from refs / Secrets / a factory). Resolution failures flow through the Taxonomy like Converge errors, falling back to ResolveFailureReason. |
Taxonomy |
reason → Terminal | FixedRequeue | Backoff. Replaces the hand-rolled classify*Error ladders with a reviewable table; unmapped reasons take Default. |
DeleteTaxonomy |
Optional per-delete-path overrides, for reasons that need a different disposition on delete than on converge (e.g. MinIO's BucketNotEmpty: terminal on converge, hold-and-re-check on delete). |
Cadences |
Per-phase requeue intervals: Ready steady-state drift resync, Progressing, and Transient (fixed-requeue + delete-retry). Zero fields take documented defaults. |
Conditions (ConditionStyle) |
Which conditions beyond Ready to maintain: EmitProgressing, EmitDegraded. Ready is always set — it is the only condition external consumers key on. |
Deletion (DeleteSemantics) |
HoldUntilSuccess (keep the finalizer until the remote delete succeeds; classify by taxonomy) vs BestEffortRelease (try once, release regardless, never wedge on a flaky remote). |
RequeueOnFinalizerAdd |
End the pass after adding the finalizer and let the update event re-trigger, vs continue in the same pass. |
DryRunAnnotation |
When set and present on the object as "true", short-circuit to Ready=True (reason DryRun) without touching the remote. |
ObservedGen (ObservedGenPolicy) |
ObservedGenOnAllSuccess (stamp on any non-error converge, including Progressing) vs ObservedGenOnConvergedOnly (stamp only on full convergence — required when a handler runs a generation-skip fast path). |
Recorder |
When non-nil, emits Warning events (size-bounded, not sanitized) on converge/delete errors. |
StampSuccess / MirrorStatus / OnStatusWrite |
Hooks: stamp last-reconciled timestamps, mirror legacy phase/ready fields and gauges before each status write, observe every status-write result. |
Two optional interfaces extend behaviour without changing the Handler
signature:
CleanupHandler[T]— when the handler implements it,RuncallsCleanupon every delete path (includingDeletionPolicy=Retain) before removing the finalizer, soRetainnever leaks operator-minted K8s-side state (e.g. credential Secrets).DeletionPolicyProvider— implemented on the object, returns the effectiveRetain/Deletepolicy (per-type empty defaults encoded in the accessor). Objects that don't implement it are treated as always-Delete.
Supporting primitives
The harness is built on small, independently usable pieces:
| Primitive | Purpose |
|---|---|
Outcome, Converged(), Progressing(), .After() |
What Converge reports back; the harness turns it into the Ready condition. Progressing is what keeps Ready from going true early. |
ReasonedError, Failf, FailfRequeue, ReasonOf, RequeueTransientOf |
Attach a condition reason to an error without touching status. FailfRequeue keeps a normally-latching reason visible while still self-healing on the transient cadence. Extractors walk wrapped chains via errors.As. |
ConditionReady/Progressing/Degraded, ReadyTrue, ReadyFalse, SetCondition, MarkDegraded, StatusObject |
The shared condition vocabulary + builders. Only Ready is consumed externally (Flux healthChecks, claim readiness). Every message is bounded to 512 bytes (MaxConditionMessageBytes) on a UTF-8 rune boundary. |
PersistStatus, StatusUpdateBestEffort |
Conflict-safe status writes that keep the caller-populated status and refresh only resourceVersion on conflict — so type-specific status fields survive a retry and remote writes stay single-shot. |
EnsureFinalizer, RemoveFinalizer, ShouldProcessDeletion |
Conflict-retried finalizer mechanics that never write through the caller's slice. |
MapSecretToRequests |
Generic Secret-watch mapper: re-enqueues the CRs whose referenced Secret name matches a Secret event. Wired in Watches(...) so a Secret landing/rotating re-triggers immediately instead of waiting out a cached "not found". |
SanitizingRecorder, NewSanitizingRecorder, EmitNormal, EmitWarning |
Event emission with every message bounded by the same 512-byte cap — including Event sites in controllers or vendored packages that call the recorder directly. |
ControllerTuning, ControllerOptions |
Uniform concurrency (MaxConcurrentReconciles) + a typed exponential-failure rate limiter (base → max delay). Wired in SetupWithManager via .WithOptions so all four operators back off consistently instead of each hardcoding its own. |
Rundrives the per-reconcile lifecycle;MapSecretToRequestsandControllerOptionsare package helpers wired at manager setup (Watches/.WithOptions), not insideRun.
Why a shared harness
The point isn't only deduplication — it's turning four loops that had quietly
disagreed into one set of explicit, auditable knobs. The divergences this
resolved, each now a named Config choice:
TransientError: requeue vs error. OpenBao mapped a transient error to a fixed 30s requeue; MinIO let the same-named reason fall through to workqueue backoff — opposite policies sharing a name. Now each is aTaxonomyentry you can read at a glance.ObservedGeneration: when to stamp. Some operators stamped observed generation on every successful pass (includingProgressing); one had to stamp only on full convergence so a generation-skip fast path didn't short-circuit its progressing loop. NowObservedGenPolicy.- Finalizer-add robustness. The add is conflict-retried, and operators can
choose to re-trigger via the update event (
RequeueOnFinalizerAdd) rather than continue in the same pass. - Deletion semantics.
HoldUntilSuccess(don't drop the finalizer until the remote delete is confirmed, classified by taxonomy) vsBestEffortRelease(release regardless, so deletion never wedges on a flaky remote) — previously an implicit per-operator habit, nowDeleteSemantics.
forgejo-operator is the design's origin: every later copy was written
"mirroring forgejo-operator," and this library is the generalization of that
loop back over all four.
Security contract
Condition reasons and messages, and Events, land in world-readable status surfaces — 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 / logs.
- Never interpolate secret material into
Failf/FailfRequeue/Converged/Progressingreasons and messages, or into Event text. Emit a generic message at credential-bearing sites. - Every condition/Event message the library writes is bounded to 512 bytes
(
MaxConditionMessageBytes). This serves both availability (a splatted remote error body can't blow etcd's per-object size limit) and confidentiality (it caps how much a stray raw error can disclose). Note this bounds disclosure — it does not sanitize; the real fix for credential paths is a generic message at the source. SanitizingRecorderapplies the same bound to Event sites that bypass the library's helpers. Wrap the manager's recorder once at wiring time.
Versioning & distribution
- Tags are immutable (the module proxy caches them forever, and the
cluster's Shipwright builds fetch through the default
GOPROXY). Never re-point a tag. - Pre-1.0 semver: breaking API changes bump the minor version. Consumers pin in
go.mod; operators do not need lockstep upgrades.
Repo layout / where to push
The canonical day-to-day remote is the in-cluster Forgejo
(code.sean.farm/.../libseanfarm-operator). A ForgejoPushMirror backs it up
to codeberg.org/someara/libseanfarm-operator roughly every 10 minutes, and
make rebuild re-seeds Forgejo from codeberg. Go module fetches read codeberg
through the default GOPROXY, so a new tag isn't visible to in-cluster builds
until the push-mirror has run (or you push the tag to codeberg directly).
Operator container images are built in-cluster by Shipwright (buildkit
strategy) and cosign-signed — never on a laptop. CI runs in-cluster via
Argo Workflows / Tekton; there are no GitHub/Forgejo Actions, and there must
never be a .github or .forgejo/workflows directory.