Shared reconcile toolkit consumed by the four seanfarm operators (Go module, fetched via GOPROXY)
  • Go 94.8%
  • Makefile 4.3%
  • Dockerfile 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
pipeline-release-prepare 754aa26cce
All checks were successful
pipeline/ci CI green v0.4.13
release: v0.4.13 (auto-release)
2026-07-23 12:50:06 +00:00
httpkit feat(extract): IsRemoteGone delete fold + Metrics kit + secret-drift helpers + httpkit 2026-07-16 14:23:58 +00:00
reconcile fix(reconcile): retry best-effort status conflicts 2026-07-23 14:46:14 +02:00
.gitattributes fix(cicd): auto-resolve CHANGELOG land conflicts via .gitattributes merge=union 2026-07-05 12:14:39 +00:00
.gitignore feat: libseanfarm-operator reconcile toolkit (Outcome, reasoned errors, PersistStatus, conditions, finalizer) 2026-06-09 18:23:54 +02:00
CHANGELOG.md release: v0.4.13 (auto-release) 2026-07-23 12:50:06 +00:00
ci.Dockerfile fix(ci): pin golang:1.25 builder base by digest 2026-07-05 13:19:44 +02:00
go.mod feat(extract): IsRemoteGone delete fold + Metrics kit + secret-drift helpers + httpkit 2026-07-16 14:23:58 +00:00
go.sum feat(reconcile): v0.2 generic harness — Run[T,C], taxonomy, deletion policy, secret-watch 2026-06-11 00:54:10 +02:00
LICENSE fix(finalizer): never write through the caller's finalizer slice 2026-06-10 23:48:16 +02:00
operator.mk feat(operator.mk): canonical shared Makefile scaffolding for the operators 2026-07-17 09:37:24 +00:00
README.md docs(readme): Recorder events are size-bounded, not sanitized 2026-07-03 22:55:31 +02:00
VERSION release: v0.4.13 (auto-release) 2026-07-23 12:50:06 +00:00

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, plus FinalizerName), and
  • a Taxonomy that classifies each failure reason as Terminal | FixedRequeue | Backoff.

Converge returns an OutcomeConverged(...) 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, Run calls Cleanup on every delete path (including DeletionPolicy=Retain) before removing the finalizer, so Retain never leaks operator-minted K8s-side state (e.g. credential Secrets).
  • DeletionPolicyProvider — implemented on the object, returns the effective Retain/Delete policy (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.

Run drives the per-reconcile lifecycle; MapSecretToRequests and ControllerOptions are package helpers wired at manager setup (Watches / .WithOptions), not inside Run.

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 a Taxonomy entry you can read at a glance.
  • ObservedGeneration: when to stamp. Some operators stamped observed generation on every successful pass (including Progressing); one had to stamp only on full convergence so a generation-skip fast path didn't short-circuit its progressing loop. Now ObservedGenPolicy.
  • 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) vs BestEffortRelease (release regardless, so deletion never wedges on a flaky remote) — previously an implicit per-operator habit, now DeleteSemantics.

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 / Progressing reasons 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.
  • SanitizingRecorder applies 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.