No description
  • Go 95.1%
  • Makefile 4%
  • Dockerfile 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-09 18:16:01 +02:00
httpkit docs: rewrite documentation to ASD-STE100 2026-08-07 18:32:27 +02:00
reconcile fix(testkit): find envtest assets in setup-envtest's own shared store 2026-08-14 17:56:49 +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
AGENTS.md docs: rewrite documentation to ASD-STE100 2026-08-07 18:32:27 +02:00
CHANGELOG.md feat!: move the module path to code.sean.farm/sean/libseanfarm-operator 2026-07-29 14:36:52 +00:00
ci.Dockerfile perf(libseanfarm): namespace BuildKit Go caches 2026-09-06 00:18:28 +02:00
go.mod feat!: move the module path to code.sean.farm/sean/libseanfarm-operator 2026-07-29 14:36:52 +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: explain native delivery and task telemetry 2026-09-09 18:16:01 +02:00
VERSION chore: devbump 0.4.13 -> 0.4.14-dev 2026-07-23 12:52:22 +00:00

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. Native cluster 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 native Binjovi tasks with BuildKit. Release signs and verifies the exact image digests. This library has its own native validation and Git release pipeline. There are no GitHub Actions and no Forgejo Actions. There must never be a .github directory or a .forgejo/workflows directory in this repo.

Native CI

Binjovi builds pull requests with the recipe in sean/binjovi-plans:recipes/libseanfarm-operator/. The active plan fixes the recipe revision for each build. Recipe changes use a plans pull request and release; they do not require a Binjovi runtime deployment.

Native task timing

Open the library dashboard and expand an execution attempt for task timings and available profiles. The pipeline finishes at the verified Git Release; this library has no Deploy target. Operators consume the released module through their own version pins.

Use binjovictl release libseanfarm-operator --build BUILD_ID --wait for an exact successful Build. Then check the Release revision and version. Telemetry describes the validation work; the stored release identity is the completion evidence.