libseanfarm-operator

module
v0.4.17 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: Apache-2.0

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

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, Run calls Cleanup on every delete path. This includes the DeletionPolicy=Retain path. Run calls Cleanup before it removes the finalizer. This way, Retain never leaks operator-minted Kubernetes-side state, such as credential Secrets.
  • DeletionPolicyProvider — Implement this interface on the object, not the handler. It returns the effective Retain or Delete policy. Each type encodes its own empty-value default in the accessor. An object that does not implement this interface is always treated as Delete.

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.

Run drives the per-reconcile lifecycle. MapSecretToRequests and ControllerOptions are package helpers. Wire both at manager setup, through Watches or .WithOptions. Neither one runs inside Run.

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 a Taxonomy entry you can read at a glance.
  • ObservedGeneration: when to stamp it. Some operators stamped observed generation on every successful pass, including a Progressing pass. 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 now ObservedGenPolicy.
  • 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. HoldUntilSuccess keeps the finalizer until the remote delete is confirmed. The harness classifies a failure by the Taxonomy. BestEffortRelease releases the finalizer regardless, so deletion never wedges on a flaky remote. This choice used to be an implicit per-operator habit. Now it is DeleteSemantics.

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, or Progressing reason 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.
  • SanitizingRecorder applies 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL