Documentation
¶
Overview ¶
Package operatorkit owns the controller-manager runtime behind the generated App.RunOperators method in forge projects.
Pattern ¶
The generated pkg/app/bootstrap.go used to open-code the controller-runtime manager setup (kubeconfig resolution, leader election, scheme registration, controller setup, manager start). Following the "generated files are tables, not programs" rule, the generated RunOperators is now a single delegation to Run with one dumb Controller row per operator:
func (a *App) RunOperators(ctx context.Context, logger *slog.Logger, healthProbeAddr string) error {
return operatorkit.Run(ctx, logger, operatorkit.Options{
LeaderElectionID: "example.com/myproj-leader",
HealthProbeBindAddress: healthProbeAddr,
}, []operatorkit.Controller{
{Name: "scaler", AddToScheme: scaler.AddToScheme,
SetupWithManager: a.Operators.Scaler.SetupWithManager},
})
}
operatorkit lives in its own package (rather than appkit proper) so projects without operators never compile controller-runtime and its Kubernetes dependency tree — the generated import is conditional on the project having operators.
Behavioural fingerprint ¶
All observable strings from the pre-table generated RunOperators are preserved verbatim:
- warn "operators disabled: no Kubernetes cluster reachable" when kubeconfig resolution fails (vanilla docker-compose dev, fresh laptop, CI without a kind/k3d cluster) — the binary continues without operators rather than crashing, matching how NATS degrades.
- "creating controller manager: <wrapped error>".
- "adding <name> scheme: <wrapped error>".
- "setting up controller %q: <wrapped error>".
- info "registered operator controller" / "starting controller manager".
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Run ¶
Run creates a controller manager, registers every controller's scheme and setup, and starts the manager. It blocks until ctx is cancelled or an error occurs; the caller runs it in a goroutine.
When no Kubernetes cluster is reachable, kubeconfig resolution fails and Run logs a warning and returns nil — the process continues without operators instead of crashing.
Types ¶
type Controller ¶
type Controller struct {
// Name is the operator's forge.yaml name — used in error messages
// and registration logs.
Name string
// AddToScheme registers the operator's CRD types on the manager's
// scheme. Optional (nil is skipped) for controllers that only watch
// built-in types.
AddToScheme func(s *runtime.Scheme) error
// SetupWithManager registers the controller with the manager.
SetupWithManager func(mgr ctrl.Manager) error
}
Controller is one generated operator row: the CRD scheme installer and the controller's manager hookup, both referenced straight off the generated operator package / constructed instance.
type Options ¶
type Options struct {
// LeaderElectionID is the lease name used for leader election — the
// scaffolded RunOperators passes "<module>-leader". Two processes that
// both run a manager and share this name contend for the SAME lease, so
// a project running more than one gives each its own value here.
LeaderElectionID string
// LeaderElectionNamespace is the namespace holding the lease.
//
// Empty is correct IN-CLUSTER: controller-runtime infers the namespace
// from the ServiceAccount mount. Out of cluster there is nothing to
// infer and NewManager hard-errors, so Run treats "reachable cluster,
// not in-cluster, no namespace" exactly like no-cluster — it warns and
// continues WITHOUT operators rather than dying. Set this to run a
// manager from a host process against a dev cluster; the value is the
// opt-in.
LeaderElectionNamespace string
// HealthProbeBindAddress, when non-empty, binds a /healthz +
// /readyz listener on that address for the controller-runtime
// manager. The generated RunOperators forwards it from
// serverkit.Config.OperatorHealthProbeAddr — which is itself projected
// from the app's typed config, so a deployment that needs a probe port
// declares one field and it arrives here. Empty leaves the manager
// without a probe listener (the default — vanilla forge projects don't
// bind one).
HealthProbeBindAddress string
// Leader-election timings. Zero takes the hardened defaults documented
// at the top of this file (45s/30s/5s), which suit the single-replica
// controller a forge project scaffolds. Raise or lower them together —
// LeaseDuration > RenewDeadline > RetryPeriod is a controller-runtime
// invariant, and a mismatched trio self-terminates a healthy leader.
LeaseDuration time.Duration
RenewDeadline time.Duration
RetryPeriod time.Duration
// ClientQPS / ClientBurst raise the manager's own client-side request
// ceiling. Zero takes the defaults above (50/100). They only apply when
// the resolved rest.Config does not already carry a value, so an
// explicit kubeconfig setting still wins.
ClientQPS float32
ClientBurst int
// ByObjectNamespaces scopes the manager cache PER OBJECT TYPE: each entry
// maps an object example (e.g. &v1alpha1.Workspace{}) to the ONLY
// namespaces the manager's informers watch/list for that type
// (controller-runtime cache.ByObject.Namespaces). Types WITHOUT an entry
// keep the default cluster-wide watch, so a controller can confine its own
// CRD to the namespace its stack deploys into while still watching
// cross-namespace workload objects (Pods/PVCs in per-user namespaces)
// everywhere.
//
// Motivation: co-located stacks on one shared cluster (e.g. dev + e2e on
// one k3d node) each run their own copy of the same operator. With a
// cluster-wide CR watch, each copy also reconciles the OTHER stack's CRs —
// a derelict controller from one stack can then stamp its own config
// (image, env) onto a sibling stack's workloads. Scoping the CR watch to
// the stack's own namespace makes cross-stack reconciliation structurally
// impossible.
//
// Entries with no namespaces (or only empty strings) are dropped — that
// type stays cluster-wide, preserving the legacy behavior when the
// deployment namespace is unknown. The scoped types' GVKs are resolved
// against the manager scheme at manager construction, so every scoped
// type MUST be registered by one of the controllers' AddToScheme hooks
// (Run registers them all before creating the manager).
ByObjectNamespaces map[client.Object][]string
// ByObjectLabels scopes the manager cache PER OBJECT TYPE by LABEL: the
// informers for that type see ONLY objects matching the selector, in
// every namespace (controller-runtime cache.ByObject.Label).
//
// This is the scoping primitive for CRs whose namespaces are not known up
// front — one per deploy environment, created at runtime — where a
// namespace list cannot be written down. A publisher stamps an ownership
// label (e.g. forge.dev/stack=<id>) and each stack's operator watches only
// its own value, so two stacks sharing a cluster cannot reconcile each
// other's CRs.
//
// A type may appear in BOTH maps; the cache then intersects them. A nil
// selector is dropped. UNLIKE ByObjectNamespaces there is no "empty means
// cluster-wide" fallback to worry about here: the caller passes a real
// selector or nothing, and refusing an empty scope is the caller's job.
ByObjectLabels map[client.Object]labels.Selector
}
Options carries the per-project manager configuration the generated row table supplies.