From b10427ea17493bd635ddd3519704aa5c2efc5df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:32:46 +0200 Subject: [PATCH 01/21] feat(plugin): add ocf plugin and marketplace manifests Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 15 +++++++++++++++ plugin/.claude-plugin/plugin.json | 14 ++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugin/.claude-plugin/plugin.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..658b505b --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "operator-component-framework", + "owner": { + "name": "sourcehawk", + "url": "https://github.com/sourcehawk" + }, + "plugins": [ + { + "name": "ocf", + "source": "./plugin", + "description": "Skills, scaffolding commands, and a guidelines reviewer for building Kubernetes operators with the operator-component-framework.", + "category": "development" + } + ] +} diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 00000000..116c1a77 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "ocf", + "displayName": "Operator Component Framework", + "version": "0.1.0", + "description": "Skills, scaffolding commands, and a guidelines reviewer for building Kubernetes operators with the operator-component-framework.", + "author": { + "name": "sourcehawk", + "url": "https://github.com/sourcehawk" + }, + "homepage": "https://sourcehawk.github.io/operator-component-framework/", + "repository": "https://github.com/sourcehawk/operator-component-framework", + "license": "Apache-2.0", + "keywords": ["kubernetes", "operator", "controller-runtime", "go"] +} From 3fb1452f36663068f5051b232f76d230e22292fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:35:28 +0200 Subject: [PATCH 02/21] feat(plugin): sync framework docs into skill references via make sync-plugin Co-Authored-By: Claude Fable 5 --- Makefile | 22 + .../references/component.md | 624 +++++++++++++ .../references/custom-resource.md | 863 ++++++++++++++++++ .../references/compatibility.md | 83 ++ .../references/guidelines.md | 606 ++++++++++++ .../testing-operators/references/testing.md | 522 +++++++++++ .../using-primitives/references/primitives.md | 497 ++++++++++ .../references/primitives/clusterrole.md | 263 ++++++ .../primitives/clusterrolebinding.md | 243 +++++ .../references/primitives/configmap.md | 327 +++++++ .../references/primitives/cronjob.md | 304 ++++++ .../references/primitives/daemonset.md | 286 ++++++ .../references/primitives/deployment.md | 275 ++++++ .../references/primitives/hpa.md | 350 +++++++ .../references/primitives/ingress.md | 336 +++++++ .../references/primitives/job.md | 250 +++++ .../references/primitives/networkpolicy.md | 251 +++++ .../references/primitives/pdb.md | 241 +++++ .../references/primitives/pod.md | 225 +++++ .../references/primitives/pv.md | 244 +++++ .../references/primitives/pvc.md | 256 ++++++ .../references/primitives/replicaset.md | 228 +++++ .../references/primitives/role.md | 228 +++++ .../references/primitives/rolebinding.md | 223 +++++ .../references/primitives/secret.md | 311 +++++++ .../references/primitives/service.md | 309 +++++++ .../references/primitives/serviceaccount.md | 185 ++++ .../references/primitives/statefulset.md | 296 ++++++ .../references/primitives/unstructured.md | 341 +++++++ 29 files changed, 9189 insertions(+) create mode 100644 plugin/skills/building-components/references/component.md create mode 100644 plugin/skills/custom-resource-wrappers/references/custom-resource.md create mode 100644 plugin/skills/structuring-operators/references/compatibility.md create mode 100644 plugin/skills/structuring-operators/references/guidelines.md create mode 100644 plugin/skills/testing-operators/references/testing.md create mode 100644 plugin/skills/using-primitives/references/primitives.md create mode 100644 plugin/skills/using-primitives/references/primitives/clusterrole.md create mode 100644 plugin/skills/using-primitives/references/primitives/clusterrolebinding.md create mode 100644 plugin/skills/using-primitives/references/primitives/configmap.md create mode 100644 plugin/skills/using-primitives/references/primitives/cronjob.md create mode 100644 plugin/skills/using-primitives/references/primitives/daemonset.md create mode 100644 plugin/skills/using-primitives/references/primitives/deployment.md create mode 100644 plugin/skills/using-primitives/references/primitives/hpa.md create mode 100644 plugin/skills/using-primitives/references/primitives/ingress.md create mode 100644 plugin/skills/using-primitives/references/primitives/job.md create mode 100644 plugin/skills/using-primitives/references/primitives/networkpolicy.md create mode 100644 plugin/skills/using-primitives/references/primitives/pdb.md create mode 100644 plugin/skills/using-primitives/references/primitives/pod.md create mode 100644 plugin/skills/using-primitives/references/primitives/pv.md create mode 100644 plugin/skills/using-primitives/references/primitives/pvc.md create mode 100644 plugin/skills/using-primitives/references/primitives/replicaset.md create mode 100644 plugin/skills/using-primitives/references/primitives/role.md create mode 100644 plugin/skills/using-primitives/references/primitives/rolebinding.md create mode 100644 plugin/skills/using-primitives/references/primitives/secret.md create mode 100644 plugin/skills/using-primitives/references/primitives/service.md create mode 100644 plugin/skills/using-primitives/references/primitives/serviceaccount.md create mode 100644 plugin/skills/using-primitives/references/primitives/statefulset.md create mode 100644 plugin/skills/using-primitives/references/primitives/unstructured.md diff --git a/Makefile b/Makefile index 0aa8ea5e..5ae19d22 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,28 @@ fmt-go: ## Format Go source files. fmt-md: prettier ## Format Markdown files. $(PRETTIER) --write '**/*.md' --ignore-path .gitignore +PLUGIN_SKILLS := plugin/skills + +.PHONY: sync-plugin +sync-plugin: ## Sync framework docs into the Claude plugin skill references. + rm -rf $(PLUGIN_SKILLS)/building-components/references \ + $(PLUGIN_SKILLS)/using-primitives/references \ + $(PLUGIN_SKILLS)/custom-resource-wrappers/references \ + $(PLUGIN_SKILLS)/structuring-operators/references \ + $(PLUGIN_SKILLS)/testing-operators/references + mkdir -p $(PLUGIN_SKILLS)/building-components/references \ + $(PLUGIN_SKILLS)/using-primitives/references/primitives \ + $(PLUGIN_SKILLS)/custom-resource-wrappers/references \ + $(PLUGIN_SKILLS)/structuring-operators/references \ + $(PLUGIN_SKILLS)/testing-operators/references + cp docs/component.md $(PLUGIN_SKILLS)/building-components/references/component.md + cp docs/primitives.md $(PLUGIN_SKILLS)/using-primitives/references/primitives.md + cp docs/primitives/*.md $(PLUGIN_SKILLS)/using-primitives/references/primitives/ + cp docs/custom-resource.md $(PLUGIN_SKILLS)/custom-resource-wrappers/references/custom-resource.md + cp docs/guidelines.md $(PLUGIN_SKILLS)/structuring-operators/references/guidelines.md + cp docs/compatibility.md $(PLUGIN_SKILLS)/structuring-operators/references/compatibility.md + cp docs/testing.md $(PLUGIN_SKILLS)/testing-operators/references/testing.md + .PHONY: prettier prettier: $(PRETTIER) ## Download prettier locally if necessary. $(PRETTIER): $(LOCALBIN) diff --git a/plugin/skills/building-components/references/component.md b/plugin/skills/building-components/references/component.md new file mode 100644 index 00000000..b4fba04f --- /dev/null +++ b/plugin/skills/building-components/references/component.md @@ -0,0 +1,624 @@ +# Component + +For operator authors implementing reconcilers. This page covers how a component is built, how it reconciles a set of +resources, and how their individual states aggregate into a single condition on the owner object. + +A **Component** groups related Kubernetes resources into one behavioral unit. It reconciles those resources, manages +their shared lifecycle (feature gating, prerequisites, suspension, grace periods, guards), and reports their aggregate +health through a single condition on the owner CRD. + +```mermaid +flowchart TD + Controller["Controller"] + Component["Component
one condition on the owner"] + Primitive["Resource Primitive
Deployment, ConfigMap, Service, ..."] + Object["Kubernetes Object"] + + Controller --> Component --> Primitive --> Object +``` + +For the broader mental model and the primitive layer beneath a component, see the [Primitives Overview](primitives.md). +For operator-structuring advice (one component per condition, thin controllers, participation modes), see the +[Guidelines](guidelines.md). + +## Building a Component + +Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle +flags, then produces an immutable `Component` ready for reconciliation. + +```go +comp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithFeatureGate(frontendFeature). // optional: disable to remove all resources + WithPrerequisite(component.DependsOn("BackendReady")). // optional: wait for another component + WithResource(frontendConfig, component.ReadOnly()). + WithResource(frontendDeployment). + WithResource(frontendService). + WithResource(legacyService, component.Delete()). + WithGracePeriod(5 * time.Minute). + Suspend(owner.Spec.Suspended). + Build() +if err != nil { + return err +} +``` + +### Resource registration options + +Each resource is registered via `WithResource`. The second argument accepts zero or more `ResourceOption` values that +control how the component interacts with the resource. A `nil` option is ignored, so a conditionally-assigned option can +be passed without a guard. + +| Option | Behavior | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| (none) | **Managed**: created or updated via Server-Side Apply; health contributes to the condition | +| `component.ReadOnly()` | **Read-only**: fetched but never modified; health still contributes | +| `component.Delete()` / `component.DeleteWhen(cond)` | **Delete**: removed from the cluster (unconditionally, or when `cond` is true); does not contribute to health | +| `component.GatedBy(gate)` | Deletes the resource when the feature gate is disabled; managed when enabled | +| `component.OrphanWhen(cond)` | **Orphan**: when `cond` is true, removes the component's owner reference and stops managing the resource, leaving the object in the cluster; does not contribute to health. Mutually exclusive with the deletion options and `ReadOnly` | +| `component.Unowned()` | **Unowned**: created and updated normally, but no controller owner reference is set; not garbage-collected on owner CR deletion | +| `component.Auxiliary()` | The resource's health does not contribute to the component condition (a blocked guard still does) | +| `component.BlockOnAbsence()` | Read-only only: a NotFound records a blocked status and short-circuits the remaining resources | +| `component.IgnoreIfAbsent()` | Read-only only: a NotFound is silently ignored and last-known state is preserved | +| `component.SuppressGraceInconsistencyWarning()` | Suppresses the grace/convergence inconsistency warning | + +A read-only resource is not owned by the component, so it is never deleted. `ReadOnly()` is mutually exclusive with +`Delete()`, `DeleteWhen()`, `GatedBy()`, and `OrphanWhen()`; combining them is a build error. `BlockOnAbsence()` and +`IgnoreIfAbsent()` each require `ReadOnly()` and are mutually exclusive with each other. To conditionally include a +read-only resource, use [`IncludeWhen`](#includewhen-vs-gatedby), which omits the resource without deleting it. + +`Unowned()` resources are created and updated by the component but are not garbage-collected when the owner CR is +deleted, because no controller owner reference is set. This is intended for resources that must outlive the management +lifecycle — for example, backup records that should persist after the application CR is removed. An `Unowned` resource +is still subject to explicit deletion: `Delete()`, `DeleteWhen()`, `GatedBy()` (when the gate is disabled), and +suspension with `DeleteOnSuspend()` all delete it directly, regardless of the `Unowned` flag. Only Kubernetes GC +(triggered by owner CR deletion) is suppressed. + +Options compose. Gate a resource and exclude it from health aggregation in one call: + +```go +component.NewComponentBuilder(). + WithName("api"). + WithConditionType("ApiReady"). + WithResource(apiDeployment). + WithResource(metricsExporter, component.GatedBy(tracingGate), component.Auxiliary()). + Build() +``` + +When `tracingGate` is disabled, the exporter is deleted. When enabled, it is managed but does not block the component +from becoming ready. + +### IncludeWhen vs. GatedBy + +These two options look similar but answer different questions, and choosing the wrong one either deletes a resource you +do not own or fails to clean up one you do: + +- **`GatedBy` / `DeleteWhen` conditionally render a resource the component owns.** When the condition turns off, the + resource is **deleted** from the cluster. Reach for these to make an owned resource exist for some states and be + removed for others. +- **`IncludeWhen` conditionally includes a resource and never deletes it.** When the condition is false the resource is + omitted entirely: not created, read, or deleted, and its constructor is never called. + +`IncludeWhen`'s primary purpose is optional, externally-owned resources that may or may not exist, most commonly a +read-only reference to a Secret or ConfigMap owned by the user or another operator behind an optional spec field. +Because construction is deferred behind the `func() Resource` closure, the builder may safely dereference the optional +input that determined inclusion. + +```go +// Optional, externally-owned read-only reference. Construction is deferred, so +// the closure only dereferences ConfigRef when it is non-nil. +builder.IncludeWhen(spec.ConfigRef != nil, func() component.Resource { + r := spec.ConfigRef + res, _ := configmap.NewBuilder(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: r.Name, Namespace: r.Namespace}, + }).Build() + return res +}, component.ReadOnly(), component.BlockOnAbsence()) +``` + +A secondary use is migrating a resource from tracked to untracked without deleting it. Moving a resource from +`WithResource` (or `IncludeWhen(true, ...)`) to `IncludeWhen(false, ...)` drops it from the component entirely: the +component no longer creates, updates, or deletes it, so an already-present resource is left in place, rather than +removed the way `GatedBy` or `DeleteWhen` would. + +!!! note "Untracking vs. releasing" + + `IncludeWhen(false, ...)` only stops the component from touching the resource; it does not remove the owner reference + the component set while the resource was managed, so Kubernetes still garbage-collects the resource when the owner is + deleted. To release a resource so it outlives its owner (for example, to migrate it to a new owner), use + [`OrphanWhen(cond)`](#resource-registration-options) instead: when the condition is true the component removes its + owner reference and stops managing the resource, leaving the object in the cluster and no longer tied to the owner's + lifecycle. + +## Feature Gates + +A component-level feature gate controls whether the component is active. When the gate is disabled, the component +deletes all of its resources and reports a `True` condition with reason `Disabled`. When enabled (or not set), the +component reconciles normally. + +```go +comp, err := component.NewComponentBuilder(). + WithName("monitoring-sidecar"). + WithConditionType("MonitoringReady"). + WithFeatureGate(monitoringFeature). + WithResource(exporterDeployment). + WithResource(exporterService). + Suspend(owner.Spec.Suspended). + Build() +``` + +A disabled feature gate takes precedence over suspension. If the gate is disabled and the component is also marked +suspended, the component is treated as disabled (resources deleted), not suspended. + +The condition when the gate is disabled: + +```yaml +type: MonitoringReady +status: "True" +reason: Disabled +message: "Component is disabled." +``` + +The `True` status follows the convention that `True` means "in its expected state", consistent with how a `Suspended` +component also reports `True`. + +!!! note + + If the gate's `Enabled()` evaluation returns an error, the component reports reason `FeatureGateError` rather than + `Disabled` or a generic `Error`. This distinct reason lets the prerequisite barrier tell a pre-prerequisite failure + apart from a post-prerequisite one. + +## Prerequisites + +Prerequisites are initialization barriers that prevent a component from reconciling until a condition is met. Unlike +resource-level [guards](#guards), prerequisites are evaluated only while the component's condition reason indicates it +has not yet proceeded past initialization. The barrier remains active while the condition reason is `Unknown`, +`PrerequisiteNotMet`, `Disabled`, or `FeatureGateError`. Once the reason changes to any other value, the barrier is +permanently passed and the prerequisite is never re-evaluated. + +This makes prerequisites suitable for startup dependencies between components. If a dependency later becomes unhealthy, +the dependent component keeps reconciling its own resources. Prerequisites answer "can this component be created?", not +"should this component keep running?". + +### Registering prerequisites + +Prerequisites are registered with `WithPrerequisite`. Multiple may be registered; all must be satisfied before the +component proceeds. + +```go +comp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithPrerequisite(component.DependsOn("BackendReady")). + WithPrerequisite(component.DependsOn("CacheReady")). + WithResource(frontendDeployment). + WithResource(frontendService). + Suspend(owner.Spec.Suspended). + Build() +``` + +The built-in `DependsOn` helper checks whether a named condition on the owner has `Status: True`. The owner is read from +the `ReconcileContext` passed to `Check`, so no cluster reads are performed. + +For custom logic, implement the `Prerequisite` interface: + +```go +type Prerequisite interface { + Check(rec ReconcileContext) (PrerequisiteResult, error) +} +``` + +### Prerequisite behavior + +- Prerequisites are evaluated before any resource is reconciled or suspended. +- The barrier is active while the condition reason is `Unknown`, `PrerequisiteNotMet`, `Disabled`, or + `FeatureGateError`. Any other reason means the component has proceeded past initialization and the barrier is + permanently passed. +- While the barrier is active, suspension is a no-op. No resources exist to suspend. +- A feature gate check runs before the prerequisite check. If the gate is disabled, prerequisites are not evaluated. +- Prerequisites are evaluated in registration order. The first unmet prerequisite short-circuits the check. +- A prerequisite error sets the component condition to `False` with reason `PrerequisiteNotMet`. + +A blocked prerequisite produces a condition like: + +```yaml +type: FrontendReady +status: "False" +reason: PrerequisiteNotMet +message: + 'Prerequisite not met: waiting for condition "BackendReady" to become True (currently False: Backend is still creating + resources)' +``` + +## Reconciliation Lifecycle + +`comp.Reconcile(ctx, recCtx)` runs the following steps on every call. They match the authoritative order in the +`Reconcile` GoDoc. + +1. **Feature gate check.** If a feature gate is set and disabled, all managed resources are deleted and the condition is + set to `True/Disabled`. No further processing occurs. A gate evaluation error sets `FeatureGateError`. +2. **Prerequisite check.** If prerequisites are registered and the initialization barrier is still active, all + prerequisites are evaluated. If any is not met, the condition is set to `False/PrerequisiteNotMet` and no resources + are reconciled or suspended. +3. **Suspension check.** If the component is marked suspended, `Suspend()` is called on all managed (non-read-only) + resources, the condition is updated to reflect suspension progress, pending deletions are processed, and the + remaining steps are skipped. Guards are not evaluated during suspension. +4. **Resource reconciliation.** All non-delete resources are processed sequentially in registration order, managed or + read-only alike. For each resource: its guard (if any) is evaluated and a blocked guard stops that resource and all + later ones; the resource is applied (managed) or fetched (read-only); its data extractors run immediately, making + extracted data available to subsequent resources' guards and mutations. +5. **Status aggregation.** The converging status of every processed resource is collected, including any blocked-guard + result. +6. **Condition update.** A new component condition is derived from the aggregate resource status, the previous + condition, and the configured grace period, then written to the owner **in memory only**. `Reconcile` never calls the + Kubernetes status API; the controller persists with [`FlushStatus`](#persisting-status-with-flushstatus). +7. **Resource deletion.** Resources registered for deletion are removed from the cluster. + +```mermaid +flowchart TD + Start([Reconcile]) --> Gate{Feature gate set?} + Gate -->|disabled| DelAll[Delete all resources] --> Disabled([True / Disabled]) + Gate -->|enabled or unset| Prereq{Barrier active
and prereqs set?} + Prereq -->|unmet| NotMet([False / PrerequisiteNotMet]) + Prereq -->|met or passed| Susp{Suspended?} + Susp -->|yes| DoSusp[Suspend managed resources] --> SuspCond([Suspension status]) --> DelMarked + Susp -->|no| Recon[Reconcile resources in order
guard / apply or fetch / extract] + Recon --> Agg[Aggregate converging status] + Agg --> Cond[Write condition in memory] + Cond --> DelMarked[Delete marked resources] + DelMarked --> End([Return; controller calls FlushStatus]) +``` + +A read-only resource registered before a managed one can extract data that feeds the managed resource's guard or +mutations within the same reconcile cycle. Read-only resources that implement `ObservationRecorder` have the fetched +object recorded back onto them so later inspection sees live cluster state; resources built from `generic.BaseResource` +do this automatically. Managed resources are applied with Server-Side Apply and receive a controller owner reference, +except where the owner is namespace-scoped and the resource is cluster-scoped (see +[Cluster-scoped resources](#cluster-scoped-resources)). + +### Previewing desired state + +`Component.Preview() ([]client.Object, error)` renders the desired state of every managed resource in registration order +without contacting the cluster. Read-only resources (fetched, not applied) and delete resources (removal markers) are +excluded. + +`Preview` does not evaluate guards. Reconcile stops at the first resource whose guard is `Blocked` and skips it and all +later ones, but a guard's outcome usually depends on cluster state and earlier extracted data, neither of which exists +in a cluster-free render. `Preview` therefore returns the full desired set, including resources a given reconcile might +skip behind a blocked guard, which keeps the snapshot deterministic and focused on baseline construction, mutation +wiring, and registration order. + +Each managed resource must implement [`concepts.Previewable`](primitives.md#lifecycle-interfaces) (`Preview()`). All +built-in primitives satisfy it through `generic.BaseResource`. A custom resource must implement it to be previewable; +without it, `Component.Preview` returns an error for that resource. `Preview` is the natural input for whole-component +golden snapshots via `golden.AssertComponentYAML`. + +```go +objs, err := comp.Preview() +if err != nil { + return err +} +for _, obj := range objs { + fmt.Printf("%s/%s\n", obj.GetNamespace(), obj.GetName()) +} +``` + +If you need the concrete Kubernetes type rather than `client.Object`, type-assert the returned value: + +```go +dep, ok := objs[0].(*appsv1.Deployment) +``` + +`Component.Resource(identity string) (Resource, bool)` looks up a registered resource by its `Identity()` string, +covering managed, read-only, and delete resources. For namespaced resources the identity is +`///` (for example `apps/v1/Deployment/default/frontend`); cluster-scoped resources +omit the namespace segment (for example `rbac.authorization.k8s.io/v1/ClusterRole/viewer`). + +The component also satisfies `concepts.MutationInspector` (`RegisteredMutations()` and `FiringSet()`), which surfaces +the names of registered mutations and the subset that fire at the version the component was built at. A custom resource +implements the same interface so version-matrix golden generation can introspect it. See +[`concepts.MutationInspector`](primitives.md#lifecycle-interfaces) for the contract and the [Testing](testing.md) guide +for how it drives version-matrix goldens. + +### Cluster-scoped resources + +When a component manages cluster-scoped resources (such as `ClusterRole` or `PersistentVolume`) and the owner CRD is +namespace-scoped, the framework **automatically skips** setting a controller owner reference on those resources. A +namespace-scoped object cannot own a cluster-scoped object. The scope of both owner and resource is determined at +reconcile time using the cluster's REST mapper; no configuration is needed, and the framework logs an info-level +message. + +!!! warning + + Without an owner reference, cluster-scoped resources are **not** garbage-collected when the owner is removed. To + ensure cleanup, either register the resource with `component.Delete()` so it is removed during reconciliation, or + add a finalizer on the owner CRD that cleans up cluster-scoped resources before the owner is deleted. + +If the owner CRD is itself cluster-scoped, owner references are set normally on all resources regardless of scope. + +## Status Model + +A component reports one condition whose reason is a `component.Status` value. Which states are reachable depends on +which [lifecycle interfaces](primitives.md#lifecycle-interfaces) a resource implements: long-running workloads report +`Alive` states, run-to-completion resources report `Completable` states, externally-dependent resources report +`Operational` states, and resources implementing none of these are ready as long as they exist. The component aggregates +across all registered resources and surfaces the most critical state. + +For the raw lifecycle-interface to status-string mapping, see +[Primitives Overview: Lifecycle Interfaces](primitives.md#lifecycle-interfaces). This page owns the priority and +aggregation behavior. + +```mermaid +stateDiagram-v2 + [*] --> Unknown + Unknown --> Creating + Creating --> Updating + Updating --> Scaling + Scaling --> Healthy + Creating --> Healthy + Healthy --> Degraded: grace expired + Healthy --> Down: grace expired + Unknown --> Disabled: gate off + Unknown --> Suspended: suspended + Creating --> Failing: cannot converge + Updating --> Failing: cannot converge + Healthy --> Error: reconcile error + note right of Healthy + Operational and Completed are + the Alive-equivalent ready states + for Operational and Completable + resources. + end note +``` + +### Condition priority and aggregation + +When several resources are aggregated into one condition, the framework selects the state with the highest priority. +`Status.Priority()` defines the order: a higher number wins. The table below lists every reason in descending priority, +so a reader can determine exactly how a failing or mixed-state component aggregates. `Error` and `FeatureGateError` +outrank everything; the ready states (`Healthy`, `Operational`, `Completed`) are the lowest non-zero priorities; +`Unknown` and any unrecognized reason are priority `0` and never influence aggregation. + +| Priority | Reason(s) | Condition status | Category | +| -------- | ------------------------------------------------ | ---------------- | ------------------------------------------ | +| 20 | `Error`, `FeatureGateError` | `False` | Reconcile or gate failure | +| 19 | `Down` | `False` | Grace expired, non-functional | +| 18 | `Degraded` | `False` | Grace expired, partially functional | +| 17 | `PendingSuspension` | `True` | Suspension acknowledged, not started | +| 16 | `Suspending` | `True` | Converging towards suspended | +| 15 | `Suspended` | `True` | Fully suspended | +| 14 | `Disabled` | `True` | Feature gate disabled | +| 13 | `AliveFailing` (`Failing`) | `False` | Workload cannot converge | +| 12 | `OperationFailing` | `False` | Integration cannot become operational | +| 11 | `CompletionFailing` (`TaskFailing`) | `False` | Task finished with an error | +| 10 | `GuardBlocked` (`Blocked`), `PrerequisiteNotMet` | `False` | Precondition not met | +| 9 | `AliveScaling` (`Scaling`) | `False` | Workload converging | +| 8 | `CompletionRunning` (`TaskRunning`) | `False` | Task running | +| 7 | `AliveUpdating` (`Updating`) | `False` | Workload converging | +| 6 | `AliveCreating` (`Creating`) | `False` | Workload converging | +| 5 | `OperationPending` | `False` | Integration waiting on a dependency | +| 4 | `CompletionPending` (`TaskPending`) | `False` | Task waiting to start | +| 3 | `Healthy` | `True` | Workload ready | +| 2 | `Operational` | `True` | Integration ready | +| 1 | `Completed` | `True` | Task finished successfully | +| 0 | `Unknown` and unrecognized | `Unknown` | Not yet reconciled; ignored in aggregation | + +!!! note + + The reason string written to the condition is the runtime status value. Several `component.Status` constants alias a + shared value: `AliveFailing` is `"Failing"`, `GuardBlocked` is `"Blocked"`, and the `Completion*` constants map to + `"Completed"`, `"TaskRunning"`, `"TaskPending"`, and `"TaskFailing"`. The parentheses in the table give the runtime + value where it differs from the constant name. + +A resource registered with [`component.Auxiliary()`](#resource-registration-options) does not contribute its converging +health to this aggregation. A blocked guard on an auxiliary resource still contributes, because a blocked guard halts +the whole pipeline. + +## Grace Period + +The grace period defines how long a component may remain in a converging state (`Creating`, `Updating`, `Scaling`) +before escalating to `Degraded` or `Down`. + +```go +component.NewComponentBuilder(). + WithGracePeriod(5 * time.Minute). + // ... +``` + +During the grace period the component reports its real converging state, not a failure. After the period expires, if the +component is still not ready, a `Graceful` resource's `GraceStatus()` determines the post-expiry severity: `Healthy` (no +issue), `Degraded` (partially functional), or `Down` (non-functional). This prevents spurious failure alerts during +normal operations such as rolling updates. See the [Guidelines](guidelines.md) for choosing grace durations. + +## Suspension + +Suspension intentionally deactivates a component without deleting its configuration. When `Suspend(true)` is set on the +builder: + +1. The component calls `Suspend()` on all `Suspendable` resources. +2. Each resource performs its suspension behavior, typically scaling to zero replicas. +3. The component polls `SuspensionStatus()` on each resource. +4. Once all resources report `Suspended`, the condition transitions to `Suspended`. + +The progression reports `PendingSuspension`, then `Suspending`, then `Suspended` (all with condition status `True`). + +Resources that do not yet exist in the cluster are created in their suspended state, with suspension mutations already +applied (a Deployment is created with zero replicas), so the resource is immediately available when suspension ends. +Resources with `DeleteOnSuspend` enabled are **not** created if already absent; their absence is treated as already +suspended, which avoids a create-then-delete loop on every reconcile while the component stays suspended. Resources that +are not `Suspendable` are left in place. + +## ReconcileContext + +`ReconcileContext` carries all dependencies for a reconciliation pass. Pass it from your controller on each call: + +```go +recCtx := component.ReconcileContext{ + Client: r.Client, // sigs.k8s.io/controller-runtime/pkg/client + Scheme: r.Scheme, // *runtime.Scheme + Recorder: r.Recorder, // record.EventRecorder + Metrics: r.Metrics, // component.Recorder (condition metrics), optional + Owner: owner, // the CRD that owns this component +} + +err = comp.Reconcile(ctx, recCtx) +``` + +Dependencies are passed explicitly so components stay testable and decoupled from global state. The `Metrics` field is +optional; when set, the framework records Prometheus metrics for every condition reported during a reconcile, using the +recorder from [go-crd-condition-metrics](https://github.com/sourcehawk/go-crd-condition-metrics). Leave it `nil` to opt +out. + +## Persisting Status with FlushStatus + +`Component.Reconcile` only mutates the owner's status conditions in memory. The controller persists them by calling +`component.FlushStatus` once per reconcile, typically from a deferred call so that conditions set on error paths are +still written: + +```go +func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { + owner := &v1alpha1.WebApp{} + if err := r.Get(ctx, req.NamespacedName, owner); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + + recCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + defer func() { + if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil { + err = flushErr + } + }() + + comp, err := buildFrontendComponent(owner) + if err != nil { + return reconcile.Result{}, err + } + return reconcile.Result{}, comp.Reconcile(ctx, recCtx) +} +``` + +`FlushStatus` performs one `Status().Update` call that writes every condition currently on the owner in memory, wrapped +in `retry.RetryOnConflict`. If another writer updated the owner between the controller's initial `Get` and this call, +`FlushStatus` refetches, reapplies the conditions staged during the reconcile, and retries. Conditions managed by other +writers on the same owner are preserved because `meta.SetStatusCondition` merges by condition type. After a successful +update, `FlushStatus` records metrics for every condition on the owner; if `Metrics` is `nil`, recording is skipped. + +This split is what lets a controller with several components stage several conditions during one reconcile and persist +them in a single write. Persisting after each component would race the components' writes and produce 409 conflicts. See +[Keep Controllers Thin](guidelines.md#keep-controllers-thin) and +[One Component Per Logical Condition](guidelines.md#one-component-per-logical-condition). + +## Guards + +Guards let resources within a component express runtime dependencies on each other. A guard is a precondition function +registered on a resource and evaluated before the resource is applied. If the guard returns `Blocked`, the resource and +all resources registered after it are skipped for that reconcile cycle. + +Combined with per-resource data extraction, guards enable indirect dependency graphs: resource A is applied first, its +data extractor populates a shared variable, and resource B's guard checks that variable before allowing B to proceed. + +### Registering a guard + +Guards are registered on the resource builder with `WithGuard`. The guard receives a copy of the resource object and +returns a `concepts.GuardStatusWithReason`. The following example shows the full pattern: a first resource extracts a +value after being applied, and a second resource guards against running before that value is available. + +```go +func buildBackendComponent(owner *v1alpha1.WebApp, endpoint *string) (*component.Component, error) { + // First resource: a config source. After it is applied, the data extractor + // reads a value from the live object into *endpoint. + configRes, err := static.NewBuilder(newBackendConfig(owner)). + WithDataExtractor(func(obj uns.Unstructured) error { + *endpoint = obj.Object["data"].(map[string]any)["endpoint"].(string) + return nil + }). + Build() + if err != nil { + return nil, err + } + + // Second resource: a consumer that needs the extracted endpoint. Its guard + // blocks until *endpoint is populated earlier in this same reconcile cycle; + // the mutation then injects the value at Mutate() time. + consumerRes, err := static.NewBuilder(newBackendConsumer(owner)). + WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { + if *endpoint == "" { + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusBlocked, + Reason: "waiting for backend endpoint", + }, nil + } + return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil + }). + WithMutation(unstruct.Mutation{ + Name: "set-endpoint", + Mutate: func(m *unstruct.Mutator) error { + m.EditContent(func(e *editors.UnstructuredContentEditor) error { + return e.SetNestedString(*endpoint, "spec", "endpoint") + }) + return nil + }, + }). + Build() + if err != nil { + return nil, err + } + + // Registration order matters: the config source must be registered before the consumer. + return component.NewComponentBuilder(). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(configRes). + WithResource(consumerRes). + Build() +} +``` + +The guard receives the resource's object but need not use it. Guards that only check external state (closure variables +populated by prior extractors) can ignore the parameter. + +### Guard behavior + +- Guards are evaluated in registration order, before each resource is applied. +- When a guard returns `Blocked`, the blocked resource contributes a `Blocked` status to the component condition + regardless of its participation mode, and all resources after it are skipped entirely. This override exists because a + blocked guard halts the entire pipeline; subsequent required resources would otherwise be silently absent from health + aggregation. +- On the next reconcile, if the guard clears (`Unblocked`), the resource is applied normally. +- Guards are **not** evaluated during suspension. The suspension path always proceeds regardless of guard state. +- A guard evaluation error is treated as a reconciliation failure and sets the condition to `Error`. + +A blocked guard produces a condition like: + +```yaml +type: BackendReady +status: "False" +reason: Blocked +message: "waiting for backend endpoint" +``` + +The `Blocked` status is not sticky. It is self-reinforcing only because the guard re-evaluates on every reconcile; when +the guard clears, the status immediately transitions to the next applicable state (for example `Creating`). + +!!! note + + `concepts.GuardStatusUnblocked` is an internal control signal returned by a guard to let reconciliation proceed. It + is never written to a condition, so you will not see `Unblocked` as a condition reason. + +## Component-Specific Guidance + +General operator-structuring advice (one component per condition, keeping controllers thin, grouping by lifecycle, +naming conditions for their audience) lives in the [Guidelines](guidelines.md). The one piece specific to this page: + +**Use `component.Auxiliary()` for non-critical resources.** A metrics-exporter sidecar should not block your primary +component from becoming ready. Every resource defaults to `ParticipationModeRequired`, so register a resource with +`component.Auxiliary()` when its health should not gate the component condition. A blocked guard on an auxiliary +resource still contributes, because a blocked guard halts the whole pipeline. See +[Understand Participation Modes](guidelines.md#understand-participation-modes) for the full discussion. diff --git a/plugin/skills/custom-resource-wrappers/references/custom-resource.md b/plugin/skills/custom-resource-wrappers/references/custom-resource.md new file mode 100644 index 00000000..f7f70e7e --- /dev/null +++ b/plugin/skills/custom-resource-wrappers/references/custom-resource.md @@ -0,0 +1,863 @@ +# Custom Resources + +This guide is for operator authors who need to manage a Kubernetes object that the [built-in primitives](primitives.md) +do not cover. The built-in set handles the common kinds (Deployments, StatefulSets, ConfigMaps, Services, and more) and +is highly customizable through status handlers, suspension logic, mutations, and data extractors. Reach for a custom +resource only when the kind you manage has no matching primitive: + +- A **custom CRD** defined by your project or a third-party operator. +- A **standard Kubernetes kind** that the built-in set does not yet wrap. + +The `pkg/generic` package provides the building blocks: it handles reconciliation mechanics, the plan-and-apply mutation +flow, suspension, guards, and data extraction. Your package wraps a generic resource with kind-specific identity, +status, and mutator logic, exactly the way the built-in primitives do. + +!!! note "If your CRD has no typed Go struct" + + You can manage any CRD without writing a wrapper at all by using the unstructured static primitive + (`pkg/primitives/unstructured/static`). See [Unstructured Primitives](primitives.md#unstructured-primitives). This + guide covers the wrapper pattern, which gives you a typed, self-documenting API for a kind you manage often. + +--- + +## Steps + +1. [Choose a resource category](#1-choose-a-resource-category) +2. [Define the mutation type alias](#2-define-the-mutation-type-alias) +3. [Implement the mutator](#3-implement-the-mutator) +4. [Implement status handlers](#4-implement-status-handlers) +5. [Implement the builder](#5-implement-the-builder) +6. [Implement the resource](#6-implement-the-resource) +7. [Define feature mutations](#7-define-feature-mutations) +8. [Register with a component](#8-register-with-a-component) + +A custom resource is three wrapped pieces. The builder configures and validates, producing a resource; the resource +delegates lifecycle methods to a generic base; the mutator records and applies changes to the Kubernetes object. + +```mermaid +flowchart LR + Builder -->|Build| Resource + Resource -->|owns base| Base["generic.*Resource"] + Resource -->|Mutate constructs| Mutator + Mutator -->|Apply| Object["Kubernetes object"] +``` + +| Your type | Wraps | +| ---------- | ------------------------------------------------------------- | +| `Builder` | `generic.WorkloadBuilder[T, *Mutator]` (or one per category) | +| `Resource` | `generic.WorkloadResource[T, *Mutator]` (or one per category) | +| `Mutator` | Implements `generic.FeatureMutator` | + +The examples below build a `MessageQueue` CRD (`messagequeues.example.io/v1`), a long-running broker with replica-based +health, so it is a **workload**. [Step 4](#4-implement-status-handlers) and the +[category notes](#category-specific-notes) show the other categories. + +--- + +## 1. Choose a resource category + +The framework defines four resource categories. Each maps to a generic resource type with a different set of lifecycle +interfaces. For the full description of each interface and the runtime string values it reports, see +[Lifecycle Interfaces](primitives.md#lifecycle-interfaces). + +| Category | Generic type | Lifecycle interfaces | Use when | +| --------------- | ----------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------ | +| **Workload** | `generic.WorkloadResource` | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | Long-running processes with replica-based health | +| **Static** | `generic.StaticResource` | `Guardable`, `DataExtractable` | Configuration objects with no runtime health semantics | +| **Task** | `generic.TaskResource` | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | Run-to-completion workloads | +| **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | + +In addition to the category-specific interfaces, every generic resource also satisfies +[`concepts.Previewable`](primitives.md#lifecycle-interfaces) and `concepts.MutationInspector`, and your wrapper exposes +both. They are covered in [Step 6](#6-implement-the-resource). + +The rest of the guide uses Workload as the primary example. The pattern is identical for the other categories, with +fewer handlers to implement. + +--- + +## 2. Define the mutation type alias + +Create a type alias for `feature.Mutation` parameterized on your mutator. This gives callers a clean name when defining +feature mutations, mirroring the `Mutation` alias each built-in primitive exports. + +```go +package messagequeue + +import "github.com/sourcehawk/operator-component-framework/pkg/feature" + +// Mutation defines a feature-gated mutation applied to a MessageQueue resource. +type Mutation = feature.Mutation[*Mutator] +``` + +--- + +## 3. Implement the mutator + +The mutator records mutation intent and applies it in a single controlled pass. It must implement +`generic.FeatureMutator`: + +```go +type FeatureMutator interface { + Apply() error + NextFeature() +} +``` + +`Apply()` executes all recorded mutations against the underlying object. `NextFeature()` advances to a new feature +scope; the framework calls it between each registered mutation to maintain per-feature ordering boundaries. + +### Plan and apply + +Mutator methods **record intent** rather than modifying the object directly. The framework calls `Apply()` once, after +all mutations have been recorded. This is the same plan-and-apply model the built-in primitives use; see +[The Mutation System](primitives.md#the-mutation-system) for the rationale and the ordering guarantees. + +```go +package messagequeue + +import ( + examplev1 "example.io/api/v1" +) + +// featurePlan groups all mutation operations recorded by a single feature. +type featurePlan struct { + replicaOps []func(*examplev1.MessageQueueSpec) + configOps []func(*examplev1.MessageQueueSpec) +} + +// Mutator records mutation intent for a MessageQueue and applies changes in one pass. +// +// It maintains feature boundaries: each feature's mutations are planned together +// and applied in the order the features were registered. +type Mutator struct { + current *examplev1.MessageQueue + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given MessageQueue. +// +// The constructor creates the initial feature scope, so mutations can be +// registered immediately. +func NewMutator(current *examplev1.MessageQueue) *Mutator { + m := &Mutator{current: current} + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations are grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. The framework calls +// this method between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// SetMaxConnections records intent to set the maximum connection count. +func (m *Mutator) SetMaxConnections(count int32) { + m.active.configOps = append(m.active.configOps, func(spec *examplev1.MessageQueueSpec) { + spec.MaxConnections = count + }) +} + +// SetReplicas records intent to set the replica count. +func (m *Mutator) SetReplicas(replicas int32) { + m.active.replicaOps = append(m.active.replicaOps, func(spec *examplev1.MessageQueueSpec) { + spec.Replicas = &replicas + }) +} + +// Apply executes all recorded mutations against the MessageQueue. +// Features are applied in registration order. Within each feature, +// replica operations are applied before config operations. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + for _, op := range plan.replicaOps { + op(&m.current.Spec) + } + for _, op := range plan.configOps { + op(&m.current.Spec) + } + } + + return nil +} +``` + +!!! note "Mutator design" + + - **Record, don't mutate.** Methods like `SetMaxConnections` append to the active feature plan. They do not touch + `current` directly. + - **Scope per feature.** `NextFeature()` opens a new plan scope. The framework calls it between registered mutations + so each feature's operations are grouped and applied in registration order. `Apply()` iterates plans + sequentially, so each feature sees the object as modified by all previous features. + - **Keep it typed.** Expose domain-specific methods (`SetMaxConnections`, `SetReplicas`) rather than generic ones. + This makes feature mutations self-documenting and keeps callers on the plan-and-apply path. The built-in workload + mutators follow the same approach, layering convenience wrappers such as `EnsureReplicas` over lower-level edits. + +--- + +## 4. Implement status handlers + +Status handlers translate your CRD's runtime state into framework status types. Which handlers you need depends on the +category. + +### Required versus optional handlers + +The generic builder's `Build()` fails if the convergence handler is missing. For workload and task resources this is the +converging-status handler registered with `WithCustomConvergeStatus`; for integration resources it is the +operational-status handler registered with `WithCustomOperationalStatus`. Every other handler defaults to a safe value +at the generic layer: + +- Grace status defaults to `Healthy` (workload and integration only). +- Suspension status defaults to `Suspended`. +- The suspension mutation defaults to a no-op. +- The delete-on-suspend decision defaults to `false`. + +Register custom handlers only where your CRD has domain-specific behavior. The workload handlers below mirror what +`pkg/primitives/deployment` registers by default. + +```go +package messagequeue + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + examplev1 "example.io/api/v1" +) + +// DefaultConvergingStatusHandler reports whether the MessageQueue has reached its desired state. +func DefaultConvergingStatusHandler( + op concepts.ConvergingOperation, mq *examplev1.MessageQueue, +) (concepts.AliveStatusWithReason, error) { + desired := int32(1) + if mq.Spec.Replicas != nil { + desired = *mq.Spec.Replicas + } + + // Defer to the generation check first, so readiness fields are not read while + // the CRD's own controller is still behind the latest spec. + if status := concepts.StaleGenerationStatus( + op, mq.Status.ObservedGeneration, mq.Generation, "messagequeue", + ); status != nil { + return *status, nil + } + + if mq.Status.ReadyReplicas == desired { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "All replicas are ready", + }, nil + } + + var status concepts.AliveConvergingStatus + switch op { + case concepts.ConvergingOperationCreated: + status = concepts.AliveConvergingStatusCreating + case concepts.ConvergingOperationUpdated: + status = concepts.AliveConvergingStatusUpdating + default: + status = concepts.AliveConvergingStatusScaling + } + + return concepts.AliveStatusWithReason{ + Status: status, + Reason: fmt.Sprintf("Waiting for replicas: %d/%d ready", mq.Status.ReadyReplicas, desired), + }, nil +} + +// DefaultGraceStatusHandler reports health once the grace period has expired. +func DefaultGraceStatusHandler(mq *examplev1.MessageQueue) (concepts.GraceStatusWithReason, error) { + desired := int32(1) + if mq.Spec.Replicas != nil { + desired = *mq.Spec.Replicas + } + + // Use == rather than >= so grace and convergence agree on replica state. + // Both handlers evaluate the same object in the same reconcile loop, so grace + // must not return Healthy for a state convergence considers non-healthy + // (e.g. ReadyReplicas > desired during scale-down). + if mq.Status.ReadyReplicas == desired { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusHealthy, + Reason: "All replicas are ready", + }, nil + } + + if mq.Status.ReadyReplicas > 0 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "MessageQueue partially available", + }, nil + } + + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "No replicas are ready", + }, nil +} + +// DefaultSuspensionStatusHandler reports progress towards a suspended state. +func DefaultSuspensionStatusHandler( + mq *examplev1.MessageQueue, +) (concepts.SuspensionStatusWithReason, error) { + if mq.Status.Replicas == 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "MessageQueue scaled to zero", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("%d replicas still running", mq.Status.Replicas), + }, nil +} + +// DefaultSuspendMutationHandler scales the MessageQueue to zero replicas. +func DefaultSuspendMutationHandler(m *Mutator) error { + m.SetReplicas(0) + return nil +} + +// DefaultDeleteOnSuspendHandler returns false: keep the resource, just scale down. +func DefaultDeleteOnSuspendHandler(_ *examplev1.MessageQueue) bool { + return false +} +``` + +### Keeping convergence and grace consistent + +The convergence handler and the grace handler evaluate the same object in the same reconcile loop, with no refetch +between them. When convergence returns `Healthy` the component is satisfied and grace is never called. For every other +state, grace must not contradict convergence by returning `Healthy`. The table below shows a consistent pair for a +workload with three desired replicas: + +| Desired | Ready | Convergence | Grace | +| ------- | ----- | ----------- | ------------ | +| 3 | 0 | Creating | Down | +| 3 | 1 | Scaling | Degraded | +| 3 | 3 | Healthy | (not called) | +| 3 | 5 | Scaling | Degraded | + +If grace reported `Healthy` in the last row, it would tell the component everything is fine while convergence still +considers the resource non-healthy (scaling down). The component logs a warning when it detects this. If the +inconsistency is intentional, pass the `component.SuppressGraceInconsistencyWarning()` resource option to `WithResource` +([Step 8](#8-register-with-a-component)) to silence the log. + +### Status constants reference + +These are the runtime **string values** each lifecycle status reports. They appear in the component's conditions and in +golden snapshots, so use the exact strings. [Lifecycle Interfaces](primitives.md#lifecycle-interfaces) gives the +authoritative interface-to-value mapping; the table here is the implementer's quick reference. + +| Category | Status type | Constant | String value | +| --------------------- | -------------------------------- | ------------------------------- | ------------------- | +| Workload | `concepts.AliveConvergingStatus` | `AliveConvergingStatusHealthy` | `Healthy` | +| | | `AliveConvergingStatusCreating` | `Creating` | +| | | `AliveConvergingStatusUpdating` | `Updating` | +| | | `AliveConvergingStatusScaling` | `Scaling` | +| | | `AliveConvergingStatusFailing` | `Failing` | +| Workload, Integration | `concepts.GraceStatus` | `GraceStatusHealthy` | `Healthy` | +| | | `GraceStatusDegraded` | `Degraded` | +| | | `GraceStatusDown` | `Down` | +| Task | `concepts.CompletionStatus` | `CompletionStatusCompleted` | `Completed` | +| | | `CompletionStatusRunning` | `TaskRunning` | +| | | `CompletionStatusPending` | `TaskPending` | +| | | `CompletionStatusFailing` | `TaskFailing` | +| Integration | `concepts.OperationalStatus` | `OperationalStatusOperational` | `Operational` | +| | | `OperationalStatusPending` | `OperationPending` | +| | | `OperationalStatusFailing` | `OperationFailing` | +| All | `concepts.SuspensionStatus` | `SuspensionStatusPending` | `PendingSuspension` | +| | | `SuspensionStatusSuspending` | `Suspending` | +| | | `SuspensionStatusSuspended` | `Suspended` | +| All | `concepts.GuardStatus` | `GuardStatusBlocked` | `Blocked` | +| | | `GuardStatusUnblocked` | `Unblocked` | + +!!! note "`Unblocked` is an internal signal" + + `GuardStatusUnblocked` is never written to a condition. It is the control value the framework uses to decide whether + to proceed with a resource. Only `Blocked` surfaces in status. + +--- + +## 5. Implement the builder + +The builder wraps the generic builder, registers default handlers in its constructor, and exposes a fluent configuration +API. It validates and returns the concrete `Resource` from `Build()`. + +The identity function is required and must produce a stable, unique identity for the object. The framework's convention, +used by every built-in primitive, is `///` (for example +`apps/v1/Deployment//`, or `v1/Service//` for core-group kinds). Cluster-scoped kinds +omit the namespace segment. Follow this format so identities stay consistent and collision-free across your operator. + +```go +package messagequeue + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + examplev1 "example.io/api/v1" +) + +// Builder configures and validates a MessageQueue resource. +type Builder struct { + base *generic.WorkloadBuilder[*examplev1.MessageQueue, *Mutator] +} + +// NewBuilder creates a Builder with the provided MessageQueue as the desired base state. +// +// The object must have Name and Namespace set. +func NewBuilder(mq *examplev1.MessageQueue) *Builder { + identityFunc := func(mq *examplev1.MessageQueue) string { + return fmt.Sprintf("messagequeues.example.io/v1/MessageQueue/%s/%s", mq.Namespace, mq.Name) + } + + base := generic.NewWorkloadBuilder[*examplev1.MessageQueue, *Mutator]( + mq, + identityFunc, + NewMutator, + ) + + // Register domain-specific defaults. + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{base: base} +} + +// WithMutation registers one or more feature-gated mutations, applied in the order given. +// Pass a slice with the spread operator: b.WithMutation(factory()...) +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +// WithGuard registers a guard precondition evaluated before the object is applied. +// If the guard returns Blocked, this resource and all resources after it in the +// component are skipped. Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func(examplev1.MessageQueue) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataExtractor registers a data extractor to run after the resource is processed. +func (b *Builder) WithDataExtractor(extractor func(examplev1.MessageQueue) error) *Builder { + b.base.WithDataExtractor(generic.WrapExtractor(extractor)) + return b +} + +// WithCustomConvergeStatus overrides the default convergence status handler. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *examplev1.MessageQueue) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides the default grace status handler. +func (b *Builder) WithCustomGraceStatus( + handler func(*examplev1.MessageQueue) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} + +// Build validates the configuration and returns the initialized Resource. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} +``` + +The builder exposes `WithCustomSuspendStatus`, `WithCustomSuspendMutation`, and `WithCustomSuspendDeletionDecision` the +same way if callers need to override suspension behavior after construction; they are omitted above for brevity. + +!!! note "Builder conventions" + + - **`generic.WrapGuard` and `generic.WrapExtractor`** convert value-receiver callbacks (`func(T)`) into the + pointer-receiver form (`func(*T)`) the generic layer expects, so your public API can take the kind by value. The + built-in builders use both. + - **Register defaults in the constructor.** Set the handlers your CRD has meaningful semantics for, then let callers + override them per resource. + - **Return `*Builder` from every method** for fluent chaining. + - **Validate in `Build()`.** The generic build checks for a non-nil object, a name, a namespace (unless + [cluster-scoped](#cluster-scoped-resources)), an identity function, a mutator factory, the required convergence + handler, and that mutation names are unique. Add any custom validation after the generic build returns. + +--- + +## 6. Implement the resource + +The resource is a thin wrapper that delegates every interface method to the generic base. This layer exists so your +package exports a concrete type rather than a generic one. List the interfaces it satisfies in its GoDoc, matching how +the built-in `Resource` types document themselves. + +```go +package messagequeue + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + examplev1 "example.io/api/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource manages a MessageQueue within a component's reconciliation loop. +// +// It implements: +// - component.Resource (Identity, Object, Mutate) +// - concepts.Alive (ConvergingStatus) +// - concepts.Graceful (GraceStatus) +// - concepts.Suspendable (DeleteOnSuspend, Suspend, SuspensionStatus) +// - concepts.Guardable (GuardStatus) +// - concepts.DataExtractable (ExtractData) +// - concepts.ObservationRecorder (RecordObservation) +// - concepts.Previewable (Preview) +// - concepts.MutationInspector (RegisteredMutations, FiringSet) +type Resource struct { + base *generic.WorkloadResource[*examplev1.MessageQueue, *Mutator] +} + +func (r *Resource) Identity() string { + return r.base.Identity() +} + +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the desired state with all feature mutations applied, without +// touching the resource's internal state or contacting the cluster. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the names of every mutation registered on the resource. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the names of registered mutations whose gate fires at the built version. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +// Compile-time guarantee that the wrapper exposes the inspection surface. +var _ concepts.MutationInspector = (*Resource)(nil) +``` + +!!! warning "Do not omit `Preview`" + + `Preview()` satisfies `concepts.Previewable`. Without it, `component.Preview()` fails at runtime and golden snapshot + tests cannot render the resource. Every built-in resource delegates `Preview()` to its base; so must yours. + +`RegisteredMutations()` and `FiringSet()` satisfy `concepts.MutationInspector`. Nothing in the reconcile path calls +them, but [version-matrix golden generation](testing.md) uses them to introspect which mutations a resource registers +and which fire at a given version. Delegate both to the base, as shown. + +Forward `RecordObservation` whenever the resource may be registered read-only with a data extractor. The framework feeds +the fetched cluster object back to the resource before extraction runs; without it, the extractor would see the inert +base passed to the builder rather than live cluster state. + +Which methods to include depends on the category: + +| Category | Methods to include | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workload | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Static | `Identity`, `Object`, `Mutate`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Task | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Integration | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | + +For task and integration resources, `ConvergingStatus` returns `concepts.CompletionStatusWithReason` and +`concepts.OperationalStatusWithReason` respectively, matching the generic base method signature. + +--- + +## 7. Define feature mutations + +Feature mutations use the `Mutation` alias from [Step 2](#2-define-the-mutation-type-alias). Each declares a name, an +optional feature gate, and a function that calls mutator methods to record intent. Name every mutation: the name is what +gating and error reporting refer to, and the builder rejects duplicate names within a resource. + +```go +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "example.io/messagequeue" +) + +// HighThroughputMode raises the connection ceiling for versions >= 2.0.0. +func HighThroughputMode(version string) messagequeue.Mutation { + return messagequeue.Mutation{ + Name: "high-throughput-mode", + Feature: feature.NewVersionGate(version, versionConstraints), + Mutate: func(m *messagequeue.Mutator) error { + m.SetMaxConnections(2000) + return nil + }, + } +} + +// ConstrainedMode caps connections when the flag is set. +func ConstrainedMode(version string, enabled bool) messagequeue.Mutation { + return messagequeue.Mutation{ + Name: "constrained-mode", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *messagequeue.Mutator) error { + m.SetMaxConnections(100) + return nil + }, + } +} + +// DefaultSettings returns baseline mutations applied to every MessageQueue. +// The version parameter is forwarded to any version-aware mutations in the set. +func DefaultSettings(version string) []messagequeue.Mutation { + return []messagequeue.Mutation{ + { + Name: "default-replicas", + Feature: nil, // always applied + Mutate: func(m *messagequeue.Mutator) error { + m.SetReplicas(1) + return nil + }, + }, + { + Name: "default-max-connections", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *messagequeue.Mutator) error { + m.SetMaxConnections(500) + return nil + }, + }, + } +} +``` + +Mutations apply in registration order. When a mutation's `Feature` is nil or its gate reports enabled, its `Mutate` +function runs; otherwise it is skipped. For the gating model (version gates, boolean `When` conditions, and how the two +combine) see [Version-Gated Mutations](primitives.md#version-gated-mutations) and +[Boolean-Gated Mutations](primitives.md#boolean-gated-mutations). + +--- + +## 8. Register with a component + +Use your custom resource with the component builder exactly like a built-in primitive. + +```go +func buildQueueComponent(owner *MyOperatorCR) (*component.Component, error) { + mq := &examplev1.MessageQueue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "main-queue", + Namespace: owner.Namespace, + }, + Spec: examplev1.MessageQueueSpec{ + Replicas: ptr.To(int32(3)), + MaxConnections: 500, + }, + } + + res, err := messagequeue.NewBuilder(mq). + WithMutation(features.HighThroughputMode(owner.Spec.Version)). + WithMutation(features.ConstrainedMode(owner.Spec.Version, owner.Spec.Constrained)). + WithMutation(features.DefaultSettings(owner.Spec.Version)...). // spread a []Mutation slice + Build() + if err != nil { + return nil, err + } + + return component.NewComponentBuilder(). + WithName("message-queue"). + WithConditionType("MessageQueueReady"). + WithResource(res). + WithGracePeriod(5 * time.Minute). + Suspend(owner.Spec.Suspended). + Build() +} +``` + +For the component reconciliation lifecycle, status aggregation, and resource options such as `ReadOnly()`, +`Auxiliary()`, and `BlockOnAbsence()`, see the [Component](component.md) page. + +--- + +## Cluster-Scoped Resources + +For cluster-scoped CRDs, call `MarkClusterScoped()` on the generic builder before building. Validation then rejects a +non-empty namespace instead of requiring one, and the identity function should omit the namespace segment. + +```go +func NewBuilder(mq *examplev1.MessageQueue) *Builder { + base := generic.NewWorkloadBuilder[*examplev1.MessageQueue, *Mutator](mq, identityFunc, NewMutator) + base.MarkClusterScoped() + // ... register handlers ... + return &Builder{base: base} +} +``` + +See [Cluster-Scoped Primitives](primitives.md#cluster-scoped-primitives) for the ownership and garbage-collection +implications. + +--- + +## Category-Specific Notes + +### Static resources + +Static resources have the simplest implementation. They do not participate in convergence, grace, or suspension +reporting. The builder uses `generic.NewStaticBuilder`, which supports `WithMutation`, `WithGuard`, and +`WithDataExtractor`. The resource wrapper needs only `Identity`, `Object`, `Mutate`, `GuardStatus`, `ExtractData`, +`RecordObservation`, `Preview`, `RegisteredMutations`, and `FiringSet`. `pkg/primitives/configmap` is a complete +reference. + +### Task resources + +Task resources use `generic.NewTaskBuilder` and report convergence as `concepts.CompletionStatusWithReason` instead of +`AliveStatusWithReason`. The converging handler, registered with `WithCustomConvergeStatus`, reports `Completed`, +`TaskRunning`, `TaskPending`, or `TaskFailing`. + +### Integration resources + +Integration resources use `generic.NewIntegrationBuilder` and report convergence as +`concepts.OperationalStatusWithReason`. The handler is registered with `WithCustomOperationalStatus` (not +`WithCustomConvergeStatus`) and reports `Operational`, `OperationPending`, or `OperationFailing`. Integration resources +also implement `Graceful`, defaulting to `Healthy`. The resource wrapper includes `GraceStatus` alongside the other +methods. A minimal integration builder for a `DNSRecord` CRD whose readiness depends on an external provider assigning a +record ID: + +```go +package dnsrecord + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + examplev1 "example.io/api/v1" +) + +// Builder configures and validates a DNSRecord integration resource. +type Builder struct { + base *generic.IntegrationBuilder[*examplev1.DNSRecord, *Mutator] +} + +// DefaultOperationalStatusHandler reports the DNSRecord operational once the +// external provider has assigned a record ID. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, r *examplev1.DNSRecord, +) (concepts.OperationalStatusWithReason, error) { + if r.Status.RecordID != "" { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Record provisioned by provider", + }, nil + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Awaiting record ID from provider", + }, nil +} + +// NewBuilder creates a Builder with the provided DNSRecord as the desired base state. +func NewBuilder(record *examplev1.DNSRecord) *Builder { + identityFunc := func(r *examplev1.DNSRecord) string { + return fmt.Sprintf("dnsrecords.example.io/v1/DNSRecord/%s/%s", r.Namespace, r.Name) + } + + base := generic.NewIntegrationBuilder[*examplev1.DNSRecord, *Mutator]( + record, + identityFunc, + NewMutator, + ) + + base.WithCustomOperationalStatus(DefaultOperationalStatusHandler) + + return &Builder{base: base} +} + +// Build validates the configuration and returns the initialized Resource. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} +``` + +`pkg/primitives/service` is a complete integration reference, including a grace handler that mirrors the operational +logic. + +--- + +## Reference + +| Package | Contains | +| ------------------------ | -------------------------------------------------------------- | +| `pkg/generic` | Generic resource types, builders, `WrapGuard`, `WrapExtractor` | +| `pkg/feature` | `Mutation`, `Gate`, `VersionGate`, `NewVersionGate` | +| `pkg/component/concepts` | Lifecycle interfaces and status type constants | +| `pkg/component` | Component builder, resource registration, reconciliation | +| `pkg/primitives/*` | Built-in implementations to use as references | + +For a complete, runnable wrapper of a third-party CRD (using the unstructured static builder rather than a typed +struct), see `examples/custom-resource`. diff --git a/plugin/skills/structuring-operators/references/compatibility.md b/plugin/skills/structuring-operators/references/compatibility.md new file mode 100644 index 00000000..da38cf27 --- /dev/null +++ b/plugin/skills/structuring-operators/references/compatibility.md @@ -0,0 +1,83 @@ +# Compatibility + +This page documents the version combinations the framework is tested against, the Go minimum requirement, the support +policy for tested combinations, and how compatibility is verified. + +## Go Requirement + +The framework requires **Go 1.25 or later** (declared in `go.mod`). Consumer projects must use Go 1.25 or later to build +against this framework. Go's toolchain version selection ensures the consumer project picks up the same minimum. + +## Supported Versions + +The framework is tested against the following version combinations: + +| Framework | controller-runtime | k8s.io/\* | Kubernetes | Go | Status | +| --------- | ------------------ | --------- | ---------- | ---- | ------- | +| main | v0.23.x | v0.35.x | 1.35 | 1.25 | Primary | +| main | v0.22.x | v0.34.x | 1.34 | 1.25 | Tested | + +**Primary** is the version combination used in `go.mod` and in the main CI pipeline. + +**Tested** combinations are verified weekly by the compatibility CI workflow. They are fully supported: bugs reported +against a Tested combination are treated as bugs in the framework, not as unsupported configurations. The distinction +from Primary is operational only (Primary is tested on every commit; Tested combinations run on a weekly schedule). + +[![Compatibility](https://github.com/sourcehawk/operator-component-framework/actions/workflows/compatibility.yml/badge.svg)](https://github.com/sourcehawk/operator-component-framework/actions/workflows/compatibility.yml) + +## Version Policy + +The framework targets the latest stable controller-runtime release as its primary dependency. Compatibility is tested +against prior controller-runtime minor versions where transitive dependencies remain compatible. When a new Kubernetes +minor version is released and controller-runtime publishes a matching release, the matrix is updated accordingly. + +As new Kubernetes minor versions are added to the matrix, the oldest Tested entry may be dropped. Dropping a combination +is announced in the release notes for the framework version that removes it. No combination is dropped without being +replaced by a newer one in the same release. + +Versions v0.21.x and below are not supported. Multiple transitive dependency module path migrations in the Kubernetes +ecosystem make those combinations irresolvable. + +## How Compatibility Is Tested + +The +[compatibility workflow](https://github.com/sourcehawk/operator-component-framework/blob/main/.github/workflows/compatibility.yml) +runs weekly on a schedule, on manual dispatch, and on pull requests labeled `compatibility`. For each version +combination in the matrix, it: + +1. Swaps the `controller-runtime` and `k8s.io/*` dependencies to the target versions using `go get`, then runs + `go mod tidy` to resolve transitive dependencies. This step is skipped for the primary (current `go.mod`) entry, + which is tested as-is. +2. Verifies that the entire module compiles (`go build ./...`). +3. Builds all examples (`make build-examples`). +4. Runs the full unit and envtest test suite (`make test`). + +The Makefile automatically detects the correct envtest binary version from the `k8s.io/api` module version, so no manual +configuration is needed when testing against different Kubernetes versions. + +## Pinning Your Kubernetes and controller-runtime Versions + +When you `go get` this framework, Go's [Minimum Version Selection](https://go.dev/ref/mod#minimal-version-selection) +(MVS) will pull your `controller-runtime` and `k8s.io/*` dependencies up to at least the versions declared in the +framework's `go.mod`. If you are already on newer versions, Go will keep yours. But if you are on older versions, MVS +will bump them. + +To prevent this, add `replace` directives to your `go.mod` that pin the versions you need: + +```go +// go.mod +replace ( + sigs.k8s.io/controller-runtime => sigs.k8s.io/controller-runtime v0.22.0 + k8s.io/api => k8s.io/api v0.34.0 + k8s.io/apimachinery => k8s.io/apimachinery v0.34.0 + k8s.io/client-go => k8s.io/client-go v0.34.0 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.34.0 +) +``` + +`replace` directives override MVS regardless of what the framework's `go.mod` declares. After adding the directives, run +`go mod tidy` to update the dependency graph. + +This works because the framework's public API surface uses abstract interfaces (`client.Object`, `client.Client`) that +remain stable across controller-runtime minor versions. The compatibility CI verifies that this downgrade path compiles +and passes tests. diff --git a/plugin/skills/structuring-operators/references/guidelines.md b/plugin/skills/structuring-operators/references/guidelines.md new file mode 100644 index 00000000..a5f13427 --- /dev/null +++ b/plugin/skills/structuring-operators/references/guidelines.md @@ -0,0 +1,606 @@ +# Guidelines + +Recommendations for structuring production operators built with the framework. These are recommendations, not hard +rules. They reflect patterns that hold up well at scale and pitfalls that are easy to walk into. Where a topic has its +own reference depth, this page links to it rather than restating it. + +The examples use a neutral domain throughout: a `WebApp` owner CRD with a `backend` (StatefulSet) component and +`frontend` and `cache` (Deployment) components, each fronted by a Service and configured by a ConfigMap or Secret. + +## Represent Desired State in the Baseline Object + +The object you pass to a primitive builder should already describe the latest desired shape of the resource. Put +everything that is always present (name, namespace, labels, selector, replicas, security context, probes, ports, primary +container) in the baseline. Mutations layer orthogonal and conditional concerns on top of a complete, valid object. + +```go +func backendStatefulSet(app *v1alpha1.WebApp) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: app.Name + "-backend", + Namespace: app.Namespace, + Labels: map[string]string{"app": app.Name, "component": "backend"}, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(app.Spec.Backend.Replicas), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": app.Name, "component": "backend"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": app.Name, "component": "backend"}, + }, + Spec: corev1.PodSpec{ + SecurityContext: restrictedPodSecurityContext(), + Containers: []corev1.Container{{ + Name: "backend", + Ports: []corev1.ContainerPort{{Name: "http", ContainerPort: 8080}}, + ReadinessProbe: httpProbe("/healthz", 8080), + // Image is intentionally left empty; a mutation owns it. + }}, + }, + }, + }, + } +} +``` + +A baseline that reads as the real resource is readable on its own, so a contributor can glance at the literal and know +the shape without replaying a stack of mutations. It also keeps mutations genuinely independent, because each one +operates on an already-valid object rather than on a half-built shell whose validity depends on earlier mutations having +run. + +Heuristic for the boundary: if a field is always present regardless of version or feature flags, it belongs in the +baseline. If it is conditional, it belongs in a mutation. + +## Mutations Are Pure Functions of the Spec + +A mutation must be a pure function of the owner spec and other inputs available at build time. It must never read the +resource's live cluster state to decide what to write. + +This is not only a style preference. Within a single resource, the framework applies mutations **before** data +extraction runs, so a closure variable populated by a data extractor on the same builder still holds its zero value when +that resource's mutations execute. Data extraction passes observed state from an **earlier** resource to a **later** +resource, not back into a resource's own mutations. + +A mutation that produces the same desired object for the same spec, regardless of what currently exists in the cluster, +aligns with Server-Side Apply's declarative model and keeps reconciliation predictable. If you find yourself wanting to +read live state inside a mutation, the mutation is encoding observation rather than intent; reconsider the design. + +## Leave Version-Dependent Fields Empty in the Baseline + +Each field should have exactly one owner. When a field's value depends on the spec version (most commonly the container +image), leave it empty in the baseline and let a single mutation set it. Splitting ownership between the baseline and a +mutation makes it ambiguous which value wins. + +```go +func backendImage(app *v1alpha1.WebApp) deployment.Mutation { + return deployment.Mutation{ + Name: "BackendImage", + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("backend"), func(e *editors.ContainerEditor) error { + e.Raw().Image = fmt.Sprintf("registry.example.com/backend:%s", app.Spec.Version) + return nil + }) + return nil + }, + } +} +``` + +The baseline owns structure; the image mutation owns the version-dependent value. When the version changes, exactly one +mutation produces the new image and nothing in the baseline contradicts it. + +## One Component Per Logical Condition + +Each component reports exactly one condition on the owner's status. If users would ask "is the backend ready?" and "is +the frontend ready?" as separate questions, those are separate components. + +```go +backendComp, err := component.NewComponentBuilder(). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(backendService). + WithResource(backendStatefulSet). + Build() + +frontendComp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(frontendService). + WithResource(frontendDeployment). + Build() +``` + +Separate components give users and monitoring granular observability: "the backend is down" is a different signal from +"the frontend is scaling," and a problem in one does not mask the status of another. + +**Split** when users would ask about the parts separately, when parts can be independently healthy or degraded, or when +a failure in one should not mask another. **Combine** when resources only make sense as a unit (a Deployment and the +Service that fronts it have no useful readiness independent of each other), or when separate conditions would add noise +without actionable information. + +Controllers typically reconcile every component and fold the per-component conditions into one top-level aggregate, for +example a `Ready` condition that names the components that are not ready. The component conditions stay granular for +debugging; the aggregate gives a single signal to gate on. See [Keep Controllers Thin](#keep-controllers-thin) for the +aggregation pattern. + +## Keep Controllers Thin + +A controller should fetch the owner, decide which components to build, reconcile each one, and defer a single +[`component.FlushStatus`](component.md#persisting-status-with-flushstatus) to persist status. Resource construction, +feature decisions, and mutation logic belong in component-building functions, which then test as pure functions: owner +in, component out, no cluster required. + +When a controller owns several components, reconcile them all, collect the first error but **continue on error** so one +failing component does not stall the rest, and flush once at the end. + +```go +func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { + app := &v1alpha1.WebApp{} + if err := r.Get(ctx, req.NamespacedName, app); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + + recCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: app, + } + // Persist all staged conditions exactly once, even on the error path. + defer func() { + if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil { + err = flushErr + } + }() + + comps, buildErr := buildComponents(app) + if buildErr != nil { + return reconcile.Result{}, buildErr + } + + var firstErr error + for _, comp := range comps { + if rErr := comp.Reconcile(ctx, recCtx); rErr != nil && firstErr == nil { + firstErr = rErr + } + } + return reconcile.Result{}, firstErr +} +``` + +`Component.Reconcile` mutates the owner's conditions **in memory only**. Persisting them is the controller's job, via +one `FlushStatus` per reconcile, deferred so that conditions set on error paths are still written when `Reconcile` +returns an error. + +!!! warning + + Do not call `FlushStatus` between component reconciles. With several components per controller, the point of the + split is to stage every condition in memory and write them once at the end. Flushing between components reintroduces + the 409 conflict pattern the split exists to avoid. + +If you do not want condition metrics, leave `ReconcileContext.Metrics` as `nil`; `FlushStatus` tolerates a nil recorder +and skips metric emission. + +Building the component set from a pure resolver `(spec, version) -> []*component.Component` keeps the loop stable: +enabling an optional feature changes which components the resolver returns without touching the reconcile loop. + +## Reconciler Error Handling and Requeueing + +The framework distinguishes between conditions and errors. A resource that is merely converging (a rolling Deployment, a +`Blocked` guard) reports its state through its condition and does **not** return an error; the framework re-queues the +owner through controller-runtime's normal watch and resync mechanics. A returned error is for a genuine fault: an API +call failed, a mutation could not be applied, a version is below the supported floor. + +Return the error from `Reconcile` and let controller-runtime apply exponential backoff. Avoid setting an explicit +`reconcile.Result{RequeueAfter: ...}` unless you have a concrete reason to poll on a fixed cadence; in most cases the +combination of resource watches and the manager's resync period already re-queues at the right time. Because +`FlushStatus` is deferred, the owner's conditions are written before the error propagates, so the failure is visible in +status even while controller-runtime backs off. + +## Resource Registration Order Is Execution Order + +Resources reconcile in the exact order they are registered with `WithResource`. This is deliberate: guards and data +extractors depend on it, and reading the calls top to bottom tells you the order with no implicit dependency graph to +reconstruct. + +Register dependencies before dependents. A common per-component bundle reads as a dependency chain: read-only Secret +references first (with [`BlockOnAbsence`](component.md#resource-registration-options) so an absent Secret blocks the +rest rather than erroring), then the ServiceAccount for workloads that need an identity, then the Service, then the +workload last. + +```go +comp, err := component.NewComponentBuilder(). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(dbCredentialsSecret, component.ReadOnly(), component.BlockOnAbsence()). // must exist first + WithResource(backendServiceAccount). + WithResource(backendService). + WithResource(backendStatefulSet). // applied last; depends on everything above + Build() +``` + +The flip side is that reordering these calls can silently break data flow between extractors and guards, so document the +dependency where one exists. + +## Mutation Ordering and Container-Name Dependencies + +Mutations within a resource also apply in registration order, and each one sees the resource as modified by all earlier +mutations. This is invisible while mutations are independent. It becomes visible when a compat mutation renames a +container and a later mutation targets that container by name. + +Two rules eliminate the problem: + +- **Use broad selectors for version-independent mutations.** `selectors.AllContainers()`, or the mutator's + `EnsureContainerEnvVar` / `EnsureContainerArg`, never reference a name, so they apply regardless of a rename and need + no ordering constraint. +- **Register name-specific mutations before the compat mutation that renames the container.** Placed before the rename, + the mutation sees the baseline name, and its edits carry through because the compat mutation overwrites only specific + fields (such as `Name` and `Ports`), not the whole container. + +```go +res, err := deployment.NewBuilder(frontendDeployment(app)). + WithMutation(debugLogging(app)). // targets ContainerNamed("frontend") by name + WithMutation(compatV1Container(app)). // renames "frontend" -> "web" for versions < 2.0 + WithMutation(tracingSidecar(app)). // AllContainers, order-insensitive + Build() +``` + +Do not work around ordering by matching multiple names (`ContainersNamed("frontend", "web")`); that couples the mutation +to every name the container has ever had. The primitives overview covers the +[ordering semantics within a feature](primitives.md#ordering-within-a-feature) in full. + +## Layer Mutations in a Fixed Order + +Order a resource's mutations into fixed layers so the pipeline reads the same way for every workload: + +1. **defaults**: the operator's desired state for the current version (image, default env, sidecars). +2. **compat**: version-gated rollbacks that restore older shapes (see below). +3. **overrides**: values from the user's spec, applied last among the value-producing layers so user input wins. +4. **checksum**: a final annotation mutation that stamps content hashes onto the pod template (see + [Provide a User-Override Escape Hatch](#provide-a-user-override-escape-hatch-as-the-last-mutation) and the rotation + pattern below). + +```mermaid +flowchart LR + B[Baseline
latest shape] --> D[defaults] + D --> C[compat
version rollbacks] + C --> O[overrides
user spec wins] + O --> H[checksum
pod-template annotations] +``` + +A field whose shape changed between versions is best handled by a **pair of mutually exclusive version gates** (`>= V` +and `< V`), so exactly one fires and the two layers never disagree. + +```go +geV := feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{atLeast("2.0.0")}) +ltV := feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}) +``` + +This layering keeps every override decision in one place and makes the compat layer self-contained, so it can shrink as +old versions drop out. + +## Prefer Reverting Compat Mutations Over Forward Mutations + +When a structural version change lands, update the baseline to the new shape and add a **revert** mutation gated on the +older versions, rather than holding the baseline at the old shape and patching it forward. The revert direction is +easier to maintain: + +- **Adding a revert mutation does not change existing ones.** Each revert handles one version step (the v2 revert turns + v3 back into v2; the v1 revert turns v2 into v1). Dropping support for a version deletes exactly one mutation. +- **Forward mutations grow fragile ordering dependencies.** A v3 forward patch may assume a v2 patch already ran; + deleting the v2 patch later breaks v3 silently. +- **You read the baseline far more often than you change it.** Baseline-as-latest shows the current shape at a glance; + baseline-as-original forces a contributor to replay every forward patch mentally. + +The cost is one new revert mutation per structural version change. That friction is a forcing function: it makes the +backward-compatibility decision explicit instead of letting old shapes silently persist as the baseline drifts. + +```go +func compatV1Container(app *v1alpha1.WebApp) deployment.Mutation { + return deployment.Mutation{ + Name: "CompatV1Container", + Feature: feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("frontend"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "web" // legacy name before 2.0 + return nil + }) + return nil + }, + } +} +``` + +A compat mutation should only **roll back**, never introduce a new field. The number of revert mutations is bounded by +the number of supported versions, and each one deletes cleanly when its version falls out of support. + +## Use Data Extraction and Guards for Intra-Component Dependencies + +When one resource depends on data from another resource in the **same** component, register a data extractor on the +source and a guard on the dependent. Do not assume a resource is ready just because it was registered earlier. + +```go +var roleARN string + +roleRes, _ := static.NewBuilder(cloudRole(app)). + WithDataExtractor(func(obj uns.Unstructured) error { + roleARN, _, _ = unstructured.NestedString(obj.Object, "status", "arn") + return nil + }). + Build() + +bucketRes, _ := static.NewBuilder(cloudBucket(app)). + WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { + if roleARN == "" { + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusBlocked, + Reason: "waiting for cloud role ARN", + }, nil + } + return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil + }). + Build() +``` + +A blocked guard surfaces as a `Blocked` condition reason, so users can see why a resource has not been created yet. The +shared variable is scoped to one reconcile, which prevents state leaking between reconciles. + +Prefer **stable** values for guard conditions. A guard re-evaluates every reconcile, so a value that can transiently +disappear (a replica count, a field cleared during a rolling update) will re-block a resource that is already running. +Good targets appear once and persist: a status field written by a controller, a provisioned IP, a generated credential +reference. + +## Use Prerequisites for Cross-Component Dependencies + +When one component cannot start until **another component** is ready, attach a prerequisite rather than orchestrating +ordering in the controller. + +```go +frontendComp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithPrerequisite(component.DependsOn("BackendReady")). + WithResource(frontendService). + WithResource(frontendDeployment). + Build() +``` + +The frontend reconciles no resources until `BackendReady` on the owner is `True`. Once the component passes through to +normal reconciliation for the first time, the prerequisite is permanently satisfied and never re-evaluated. + +Prerequisites are for **startup** ordering, not ongoing health. If the backend goes down after the frontend is already +running, the frontend keeps reconciling its own resources; the two conditions reflect their own health independently. +Contrast with [guards](#use-data-extraction-and-guards-for-intra-component-dependencies), which work within a single +component and re-evaluate every reconcile. See the [prerequisite behavior](component.md#prerequisite-behavior) section +for the full lifecycle. + +## Use Feature Gates for Optional Components and Conditional Resources + +Gate optional pieces with a feature gate rather than branching in the controller. The framework then owns the full +lifecycle, including deletion when the gate flips off. + +For an entire optional component, use a **component** gate: + +```go +cacheComp, err := component.NewComponentBuilder(). + WithName("cache"). + WithConditionType("CacheReady"). + WithFeatureGate(feature.NewVersionGate(app.Spec.Version, nil).When(app.Spec.Cache.Enabled)). + WithResource(cacheService). + WithResource(cacheDeployment). + Build() +``` + +When the gate is disabled the framework deletes the component's resources and reports `True/Disabled`. A disabled gate +takes precedence over suspension. + +For a single optional resource the component owns, use [`component.GatedBy`](component.md#feature-gates) on +`WithResource`: + +```go +comp, _ := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(frontendDeployment). + WithResource(tracingConfigMap, component.GatedBy(tracingGate)). // deleted when the gate is off + Build() +``` + +A disabled `GatedBy` gate deletes the resource on the next reconcile. For an optional resource the component does +**not** own (a read-only Secret reference behind an optional spec field), use `IncludeWhen`, which omits the resource +without ever deleting it. The [IncludeWhen vs. GatedBy](component.md#includewhen-vs-gatedby) section covers the +distinction. + +## Provide a User-Override Escape Hatch as the Last Mutation + +Give users a documented way to override operator-emitted values, applied as the last value-producing mutation so their +input shadows the defaults. A common shape is an optional `spec.ExtraEnv` applied through `EnsureEnvVars` behind a +`.When` gate. + +```go +func extraEnv(app *v1alpha1.WebApp) deployment.Mutation { + envs := app.Spec.Frontend.ExtraEnv + return deployment.Mutation{ + Name: "ExtraEnv", + Feature: feature.NewVersionGate(app.Spec.Version, nil).When(len(envs) > 0), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("frontend"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVars(envs) + return nil + }) + return nil + }, + } +} +``` + +Because `EnsureEnvVars` replaces existing entries by name, registering this mutation after the operator's own env +mutations lets a user value shadow an operator-emitted one without you enumerating every overridable field. + +A related use of a final mutation is **secret-rotation restart**: each read-only Secret has a data extractor that hashes +its contents into a shared map, and a final mutation stamps that map onto the pod template as annotations through +`EditPodTemplateMetadata`. A Secret rotation changes a hash, which changes the pod template, which triggers a rolling +restart. Keep the map empty during preview so golden snapshots stay stable. + +```go +func checksumAnnotations(hashes map[string]string) deployment.Mutation { + return deployment.Mutation{ + Name: "ChecksumAnnotations", + Mutate: func(m *deployment.Mutator) error { + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + for k, v := range hashes { + e.EnsureAnnotation("checksum/"+k, v) + } + return nil + }) + return nil + }, + } +} +``` + +## Fail Loudly Below the Supported Version Floor + +A version below the supported floor should produce a loud error, not a silently wrong workload. When a compat mutation +cannot faithfully represent a version, return an error from `Mutate` rather than emitting an approximation. + +```go +func compatV1Container(app *v1alpha1.WebApp) deployment.Mutation { + return deployment.Mutation{ + Name: "CompatV1Container", + Feature: feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}), + Mutate: func(m *deployment.Mutator) error { + if belowFloor(app.Spec.Version, "1.0.0") { + return fmt.Errorf("version %s is below the supported floor 1.0.0", app.Spec.Version) + } + // ... roll back to the legacy shape + return nil + }, + } +} +``` + +The error propagates out of `Component.Reconcile`, and because [`FlushStatus`](#keep-controllers-thin) is deferred, the +failure is recorded on the owner's condition where an operator can see it. + +## Name Mutations for Golden Introspection + +Give every mutation a `Name`. Names appear in error reporting, and version-matrix golden manifests reference them in +their `requires` and `forbids` lists, so descriptive names keep those manifests self-documenting. Name compat mutations +after what they restore (`CompatV1Container`), so a reader scanning a builder chain understands each entry without +opening its implementation. See [testing.md](testing.md#firing-set-classification) for how named mutations drive +firing-set classification. + +## Understand Participation Modes + +[`component.Auxiliary()`](component.md#resource-registration-options) means "reconciled but not required for health." It +does not mean "skipped." A failing auxiliary resource still fails the reconciliation; the only difference is that its +health does not affect whether the component condition becomes Ready. + +```go +comp, _ := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(frontendDeployment). // required for Ready + WithResource(metricsExporter, component.Auxiliary()). // not required for Ready + Build() +``` + +Use `Auxiliary` for supporting resources (metrics exporters, debug sidecars, optional integrations) whose health should +not block the component from reporting Ready. + +!!! note + + A blocked guard always contributes to the condition regardless of participation mode. A blocked guard halts the + reconciliation pipeline, and that must be visible in the condition. + +## Grace Periods Are Convergence Time + +A component in `Creating` or `Updating` for a few minutes during a rolling update is normal, not a failure. The grace +period gives a component time to converge before the framework escalates the condition to `Degraded` or `Down`. + +```go +comp, _ := component.NewComponentBuilder(). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(backendStatefulSet). + WithGracePeriod(5 * time.Minute). + Build() +``` + +Set the grace period to how long the resource legitimately takes to converge. A workload with a large image pull or a +slow readiness probe needs a longer grace period than a ConfigMap update. A very long grace period delays detection of +genuine failures, so choose a value that reflects expected convergence time, not a safety margin. + +## Handle Cluster-Scoped Resources Explicitly + +When a namespace-scoped owner manages cluster-scoped resources (`ClusterRole`, `ClusterRoleBinding`), Kubernetes does +not allow cross-scope ownership, so the framework cannot set an owner reference. It detects this, skips the reference, +and logs the skip with its garbage-collection implication. + +The consequence is that those resources are **not** garbage-collected when the owner is deleted. Clean them up +explicitly with [`component.Delete()`](component.md#resource-registration-options) (or `DeleteWhen`) and a finalizer on +the owner CRD that keeps the owner alive until its cluster-scoped resources are removed. + +```go +comp, _ := component.NewComponentBuilder(). + WithName("rbac"). + WithConditionType("RBACReady"). + WithResource(clusterRole, component.Delete()). + Build() +``` + +The [cluster-scoped resources](component.md#cluster-scoped-resources) section covers the ownership and deletion behavior +in full. + +## Name Resources to Avoid Multi-Tenant Collisions + +A single operator typically reconciles many owner instances in many namespaces. Derive every managed resource's name +from the owner so two owners never collide. Prefix namespace-scoped resources with the owner name +(`app.Name + "-backend"`), and for **cluster-scoped** resources, which share one global namespace, include the owner's +namespace too (`app.Namespace + "-" + app.Name + "-reader"`). + +```go +clusterRoleName := fmt.Sprintf("%s-%s-reader", app.Namespace, app.Name) +``` + +A cluster-scoped resource named after the owner alone collides the moment two namespaces hold an owner with the same +name. Encoding the namespace in the name keeps each instance's resources distinct. + +## Name Conditions for the Audience Reading Them + +Condition types appear in `kubectl get` output and on dashboards. Name them for the person or system consuming that +output, after the capability, not the Kubernetes resource type backing it. + +**Prefer:** `BackendReady`, `FrontendReady`, `MigrationComplete`. + +**Avoid:** `StatefulSetHealthy`, `DeploymentReconciled`, `JobFinished`. + +A condition named `DeploymentReconciled` tells a user nothing about which capability is affected. `BackendReady` does. + +## Pin Rendered Output Across Supported Versions + +Every supported version's rendered output should be covered by a golden, so that when you change the baseline you can +prove older versions still render what they did before and that the change touched only the version you intended. This +is the safety net that lets you keep the baseline at the latest shape (see +[Represent Desired State in the Baseline Object](#represent-desired-state-in-the-baseline-object)) without silently +regressing older ones. + +Use `goldengen.Resource` rather than a hand-written loop with one golden per version. It sweeps the versions, collapses +them into firing regimes (one golden per distinct set of firing mutations, not one per version), asserts which mutations +fire at each version, and proves through `AssertComplete` that every registered mutation is covered. A new version that +fires the same mutations as an existing one adds no golden; a version that crosses a gate boundary gets its own. See +[Testing](testing.md) for the mechanics. + +After a deliberate baseline change, regenerate with `go test ./path -update` and review the diff. Only the regimes you +meant to change should move. If an older regime's golden shifts, a compat mutation broke, and the diff shows exactly +what. + +## Further Reading + +For a deeper look at the structural problems these guidelines address, see +[The Missing Layers in Your Kubernetes Operator](https://medium.com/@sourcehawk/the-missing-layers-in-your-kubernetes-operator-306ee8633350). + diff --git a/plugin/skills/testing-operators/references/testing.md b/plugin/skills/testing-operators/references/testing.md new file mode 100644 index 00000000..fd2cd25b --- /dev/null +++ b/plugin/skills/testing-operators/references/testing.md @@ -0,0 +1,522 @@ +# Testing + +The framework ships two test-only packages: `pkg/testing/golden` for single-build snapshot tests and +`pkg/testing/goldengen` for declarative coverage across versions and specs. Both are opt-in and import nothing into the +reconcile path, so a consumer that does not test against them pays nothing. This page organizes them around three +testing layers. + +## The three layers + +Test a component from the inside out. Each layer asserts something the layer below cannot: + +| Layer | What you assert | Tool | +| ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------- | +| **Mutation** | one mutation makes the field changes you intend, on a baseline | testify, against `Preview()` | +| **Resource** | the right mutations fire for a spec, and the rendered output is pinned | `golden` for a snapshot, `goldengen.Resource` for coverage | +| **Component** | the whole component renders the resources you expect, applied together | `golden.AssertComponentYAML`, or `goldengen.Component` | + +The +[`mutations-and-gating` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating) +demonstrates all three, and the +[`version-matrix` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/version-matrix) +is a focused walkthrough of `goldengen`. + +## Mutation tests + +Unit-test a mutation in isolation: build a minimal baseline primitive with only that mutation, preview it, and assert +the fields it changed. There is no golden file at this layer; the assertion states intent directly. + +```go +func TestDebugLoggingMutation(t *testing.T) { + res, err := deployment.NewBuilder(baseDeployment()). + WithMutation(features.DebugLoggingMutation(true)). + Build() + require.NoError(t, err) + + dep, err := res.Preview() + require.NoError(t, err) + + container := dep.(*appsv1.Deployment).Spec.Template.Spec.Containers[0] + assert.Contains(t, container.Env, corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) +} +``` + +Share the minimal `baseDeployment()` / `baseConfigMap()` baselines across a package's mutation tests in a +`helpers_test.go` so each test declares only what it exercises. + +## Golden snapshots + +`golden` renders a built primitive or component to canonical YAML and compares it against a checked-in file. The +serialization resolves `TypeMeta` (from the object or a supplied scheme) and strips zero-value noise fields, so the +golden reflects only the meaningful desired state. + +### golden.WithScheme is effectively mandatory + +Typed Kubernetes objects (all built-in primitives and standard `k8s.io/api` types) do not populate `TypeMeta` by +default. Attempting to serialize such an object without a scheme produces an error: + +``` +object *v1.Deployment has incomplete TypeMeta (kind="", apiVersion="") and no scheme was provided +``` + +Pass `golden.WithScheme(scheme)` to every `AssertYAML` and `AssertComponentYAML` call. The scheme only needs to register +the types you are serializing; the same scheme you use in your controller's manager is normally sufficient. + +```go +var scheme = runtime.NewScheme() + +func init() { + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) +} +``` + +### The Previewer and ComponentPreviewer contracts + +`AssertYAML` accepts a `golden.Previewer`: + +```go +type Previewer interface { + Preview() (client.Object, error) +} +``` + +`AssertComponentYAML` accepts a `golden.ComponentPreviewer`: + +```go +type ComponentPreviewer interface { + Preview() ([]client.Object, error) +} +``` + +All built-in primitives satisfy `Previewer` through `generic.BaseResource`. A built `*component.Component` satisfies +`ComponentPreviewer` through its `Preview` method. If you are implementing a custom resource wrapper, your built +resource must also satisfy `Previewer` for golden tests to work. See [Custom Resources](custom-resource.md) for how to +implement `Preview` on a custom resource. + +### Assert a single resource + +`AssertYAML` previews a built primitive, serializes it, and fails the test on any difference from the golden file. The +test helpers live in `github.com/sourcehawk/operator-component-framework/pkg/testing/golden`; `app` and `resources` are +your own packages, and `scheme` is the package-level scheme from the section above. + +```go +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + + "your.module/app" + "your.module/resources" +) + +var update = flag.Bool("update", false, "update golden files") + +func TestDeploymentGolden(t *testing.T) { + owner := &app.ExampleApp{Spec: app.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}} + owner.Name = "my-app" + owner.Namespace = "default" + + res, err := resources.NewDeploymentResource(owner) + require.NoError(t, err) + + previewer, ok := res.(golden.Previewer) + require.True(t, ok) + golden.AssertYAML(t, "testdata/deployment.yaml", previewer, + golden.WithScheme(scheme), golden.Update(*update)) +} +``` + +`resources.NewDeploymentResource` returns a `component.Resource`, the lean interface the reconciler uses. Rendering is a +separate capability, so the test asserts to `golden.Previewer` (the contract shown above); for any built-in primitive +the assertion always succeeds, since `generic.BaseResource` implements `Preview`. + +`golden.Update(*update)` overwrites the golden file (creating intermediate directories) instead of comparing. Generate +the golden once, inspect it, then commit it: + +```bash +go test ./path/to/pkg -run TestDeploymentGolden -update +go test ./path/to/pkg -run TestDeploymentGolden +``` + +!!! note + + The `-update` flag goes **after** the package path, not before it. `go test -update ./...` passes `-update` to + `go test` itself, which rejects it. The correct form is `go test ./path/to/pkg -update`. + +Golden files live in a `testdata/` directory next to the test file. Go excludes `testdata/` from the build by +convention, so the files are invisible to the compiler. + +### Assert a component + +`AssertComponentYAML` previews every resource a component would apply and serializes them into one multi-document YAML +stream (`---` separated, in apply order). `buildComponent` here is your own helper that assembles the component with +`component.NewComponentBuilder` (see [Getting Started](getting-started.md#step-5-wire-the-reconciler) for building one); +extract it from your reconciler so the test and the controller build the component the same way. A built +`*component.Component` satisfies `golden.ComponentPreviewer` directly, so no type assertion is needed. + +```go +func TestComponentGolden(t *testing.T) { + owner := &app.ExampleApp{Spec: app.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}} + owner.Name = "my-app" + owner.Namespace = "default" + + comp, err := buildComponent(owner) // your component-building helper + require.NoError(t, err) + + golden.AssertComponentYAML(t, "testdata/component.yaml", comp, + golden.WithScheme(scheme), golden.Update(*update)) +} +``` + +Generate and verify with the same `-update` pattern: + +```bash +go test ./path/to/pkg -run TestComponentGolden -update +go test ./path/to/pkg -run TestComponentGolden +``` + +### Non-testing variants and out-of-band serialization + +Both helpers have non-`testing.T` variants that return a `*MismatchError` (carrying a unified diff) instead of failing a +test, for use outside a test body: + +- `CompareYAML(path string, p Previewer, opts ...Option) error` +- `CompareComponentYAML(path string, c ComponentPreviewer, opts ...Option) error` + +When you need the canonical YAML bytes directly (to feed a custom comparison or generate goldens from a tool), call the +serializers directly: + +```go +data, err := golden.Serialize(obj, scheme) // one object +stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream +``` + +`goldengen` is built on exactly these two functions. + +## Coverage with goldengen + +`goldengen` is the declarative way to do the resource and component layers when you want coverage rather than a single +snapshot. It sweeps a set of versions and specs, asserts which mutations fire at each, writes one golden per distinct +firing group, and proves through `AssertComplete` that no registered mutation went untested. + +It works at either granularity through one `Unit` abstraction: wrap a built resource with +`goldengen.Resource(res, scheme)` for resource-level coverage, or a built component with +`goldengen.Component(comp, scheme)` for component-level coverage. Everything below (fixtures, gating assertions, the +manifest, completeness) applies the same to both. + +!!! note + + `goldengen` classifies firing and checks completeness by reading each unit's `RegisteredMutations()` and + `FiringSet()`, the `concepts.MutationInspector` interface every built resource and component implements. You rarely + call it directly; `goldengen` is the supported way to assert which mutations fire. + +A resource with version-gated mutations behaves differently across versions, but not at every version: behavior changes +only where a gate flips. Asserting one golden per version is wasteful and obscures where behavior actually changes. +`goldengen` groups the swept versions by which mutations fire and writes one golden per distinct group. + +The worked example lives at +[`examples/version-matrix`](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/version-matrix) +(a single resource); the +[`mutations-and-gating` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating) +applies the same harness at both the resource and component layers. The walkthrough below follows the version-matrix +example. + +### Declare the matrix + +A `Config[T]` declares the whole matrix. `T` is your fixture spec type (a custom resource, or any value your build +function accepts). + +```go +var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ + Dir: "testdata/version_matrix", + Versions: []string{"1.0.0", "1.5.0", "2.0.0"}, + Fixtures: []goldengen.Fixture[*app.ExampleApp]{{ + Name: "default", + Spec: defaultCluster(), + Requires: []goldengen.Expect{ + {Name: "ContainerImage"}, + {Name: "PeerDiscovery/PreV2", For: "1.5.0"}, + {Name: "PeerDiscovery/V2", For: "2.0.0"}, + }, + Forbids: []goldengen.Expect{ + {Name: "PeerDiscovery/V2", For: "1.5.0"}, + {Name: "PeerDiscovery/PreV2", For: "2.0.0"}, + }, + }}, + Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) { + c := spec.DeepCopyObject().(*app.ExampleApp) + c.Spec.Version = version + res, err := resources.NewStatefulSetResource(c) + if err != nil { + return nil, err + } + return goldengen.Resource(res, scheme), nil + }, +}) +``` + +The fields: + +- **`Dir`** roots the generated goldens and the manifest. +- **`Versions`** is the version universe to sweep, in the order you supply (see [version ordering](#version-ordering)). +- **`Fixtures`** are the specs to build and assert. Each names its own golden subdirectory. +- **`Exclude`** (omitted above) lists registered mutation names you deliberately leave unasserted, so they do not fail + the [completeness check](#completeness-accounting). It does not affect gating or golden generation. +- **`Build`** materializes a `Unit` from a fixture spec at a version. It must apply the version to the spec so the gates + evaluate against it. Copy the spec before mutating it, since `Build` is called once per version for the same fixture. + +`Build` returns a `Unit`, the introspectable-and-renderable handle the generator works with. Adapt a built primitive +with `goldengen.Resource(res, scheme)` or a built component with `goldengen.Component(comp, scheme)`. Both delegate +rendering to `golden.Serialize` / `golden.SerializeComponent`. For component-level coverage, build the whole component +in `Build` and wrap it instead; everything else (fixtures, gating assertions, the manifest, `AssertComplete`) is +identical: + +```go +Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) { + c := spec.DeepCopyObject().(*app.ExampleApp) + c.Spec.Version = version + comp, err := buildComponent(c) // returns *component.Component, as your reconciler builds it + if err != nil { + return nil, err + } + return goldengen.Component(comp, scheme), nil +}, +``` + +A component's registered and firing sets are the union of its resources' mutations, deduplicated. So at the component +layer, `Requires`/`Forbids` and `AssertComplete` range over every mutation any resource in the component registers, not +a separate component-level set. + +`goldengen.Resource` requires that the primitive satisfies both `concepts.MutationInspector` (for `RegisteredMutations` +and `FiringSet`) and `concepts.Previewable` (for `Preview`); `goldengen.Component` requires the equivalent on a +`*component.Component`. All built-in primitives satisfy both through `generic.BaseResource`, and a built component +satisfies them by aggregating its resources. For custom resources, see [Custom Resources](custom-resource.md) for how to +implement `MutationInspector`. + +### Run the sweep + +Wire a `-update` flag through `WithUpdate` and call `Run` from a normal test: + +```go +var update = flag.Bool("update", false, "update golden files") + +func TestVersionMatrix(t *testing.T) { + gen.WithUpdate(*update) + gen.Run(t) +} +``` + +`Run` validates the config, builds every fixture at every version, asserts the gating, then writes (under `-update`) or +compares one golden per regime plus the manifest. Generate the goldens once, inspect them, then commit: + +```bash +go test ./examples/version-matrix/ -run TestVersionMatrix -update +go test ./examples/version-matrix/ +``` + +### Firing-set classification + +The firing set at a version is the set of registered mutations whose gate is enabled there (a mutation with no gate +fires unconditionally). A **regime** is a maximal group of swept versions sharing an identical firing set. `goldengen` +writes one golden per regime, named after the regime's representative, instead of one golden per version. + +In the example, the universe `1.0.0`, `1.5.0`, `2.0.0` collapses to two regimes: + +```mermaid +flowchart LR + v1["1.0.0"] --> r1 + v2["1.5.0"] --> r1 + v3["2.0.0"] --> r2 + r1["regime: ContainerImage + PeerDiscovery/PreV2
golden: default/1.0.0.yaml"] + r2["regime: ContainerImage + PeerDiscovery/V2
golden: default/2.0.0.yaml"] +``` + +`1.0.0` and `1.5.0` fire the same set, so they share one golden; `2.0.0` crosses the `PeerDiscovery` boundary into its +own regime. Two goldens cover three versions, and adding more versions inside an existing regime adds no goldens. + +### Version ordering + +The representative of a regime is the first version in supplied order that belongs to it. Listing `Versions` ascending +therefore puts each representative on the **lower inclusive boundary** of its gating range, so the golden's filename +marks exactly where the regime begins. In the example, `default/2.0.0.yaml` is named for the first version at which the +newer peer-discovery regime takes effect. List versions ascending unless you have a specific reason not to. + +### The four assertions + +Per fixture you assert gating with `Requires` and `Forbids`, each a list of `Expect{Name, For}`. `For` is optional; when +set it must be a version drawn from `Versions`. + +| Assertion | `For` set | Meaning | +| --------------------- | --------- | ---------------------------------------------- | +| `Requires{Name}` | no | the mutation fires at **some** swept version | +| `Requires{Name, For}` | yes | the mutation fires **at that version** | +| `Forbids{Name}` | no | the mutation fires at **no** swept version | +| `Forbids{Name, For}` | yes | the mutation **does not** fire at that version | + +Pin both sides of a boundary to assert it precisely: in the example `PeerDiscovery/V2` is required at `2.0.0` and +forbidden at `1.5.0`, which locks the gate to exactly the `2.0.0` boundary rather than merely "fires somewhere". + +### Completeness accounting + +`AssertComplete` proves no registered mutation slips through unasserted. Call it from `TestMain`, passing the result of +`m.Run()`: + +```go +func TestMain(m *testing.M) { + os.Exit(gen.AssertComplete(m.Run())) +} +``` + +With more than one generator in a package (say a resource matrix and a component matrix), there is still one `TestMain`; +chain the accounting so a violation in either fails the package: + +```go +func TestMain(m *testing.M) { + code := m.Run() + code = resourceGen.AssertComplete(code) + code = componentGen.AssertComplete(code) + os.Exit(code) +} +``` + +Accounting holds when the universe of registered mutation names across all fixtures equals +`union(Requires names) ∪ Exclude`. `AssertComplete` returns the incoming code unchanged when the tests already failed (a +nonzero code) or when accounting holds; otherwise it prints the violations to stderr and returns a nonzero code. The +violations are: + +- a registered mutation that is neither required by a fixture nor listed in `Exclude` (an unasserted mutation), +- a name in `Requires` or `Exclude` that no fixture actually registers (a stale assertion), and +- a registered mutation with an empty name. + +The effect: registering a new version-gated mutation fails the suite until you either assert it with a `Requires` or +deliberately set it aside with `Exclude`. + +`AssertComplete` checks coverage, not firing. It confirms every registered mutation is named in a `Requires` or +`Exclude`; it never evaluates whether a mutation fired. Firing is verified separately, when `Run` checks each `Requires` +during the sweep. The two compose: `AssertComplete` forces every mutation to be asserted, and the `Requires` it forces +you to write then proves the mutation actually fires. + +| Check | Runs | Fails when | +| ---------------- | ---------------- | ------------------------------------------------------------ | +| `Requires{Name}` | during the sweep | the named mutation does **not** fire | +| `Forbids{Name}` | during the sweep | the named mutation **does** fire | +| `AssertComplete` | from `TestMain` | a registered mutation is in neither `Requires` nor `Exclude` | + +`Requires` and `Forbids` assert behavior (firing); `AssertComplete` asserts coverage, on registration. Nothing fails +merely because a mutation fired without a matching `Requires`. The coverage net is registration-based: every registered +mutation must be required or excluded. + +### The manifest + +Alongside the goldens, `Run` writes `/manifest.yaml`, a reviewable coverage map: per fixture, each regime with its +representative version, the versions it covers, and the shared firing set. + +```yaml +fixtures: + - name: default + regimes: + - representative: 1.0.0 + versions: + - 1.0.0 + - 1.5.0 + firing: + - ContainerImage + - PeerDiscovery/PreV2 + - representative: 2.0.0 + versions: + - 2.0.0 + firing: + - ContainerImage + - PeerDiscovery/V2 +``` + +Reviewing the manifest diff in a pull request shows at a glance how the gating coverage changed: a new regime, a moved +boundary, or a mutation that started or stopped firing. + +## YAML matrix loader + +The matrix can be declared in YAML instead of Go, keeping the version universe and fixtures as data while the build +function stays in code. `LoadMatrix` reads the file and returns a ready-to-run `Config[T]`: + +```go +func LoadMatrix[T any]( + path string, + newSpec func() T, + build func(version string, spec T) (Unit, error), +) (Config[T], error) +``` + +`newSpec` returns a fresh, empty spec to unmarshal a fixture into, called once per fixture at load time, not per build. +`build` is the same callback you would set on a Go `Config`, including the deep copy: it receives the loaded fixture +spec, which `goldengen` reuses across every version in the sweep, so it must copy the spec before setting the version, +exactly as the [Go `Config.Build`](#declare-the-matrix) does. It supplies the scheme by passing the built unit through +`goldengen.Resource` or `goldengen.Component`. The returned config is validated before it is returned. + +A matrix file mirrors `Config` minus the Go-only `build`. Each fixture supplies its spec either inline under `spec:` or +from an external file under `specFile:` (resolved relative to the matrix file), exactly one of the two: + +```yaml +dir: testdata/version_matrix +versions: + - "1.0.0" + - "1.5.0" + - "2.0.0" +exclude: [] +fixtures: + - name: default + spec: # inline custom resource + apiVersion: apps.example.io/v1 + kind: ExampleApp + metadata: + name: demo + namespace: default + spec: + version: 1.0.0 + requires: + - { name: ContainerImage } + - { name: PeerDiscovery/PreV2, for: "1.5.0" } + - { name: PeerDiscovery/V2, for: "2.0.0" } + forbids: + - { name: PeerDiscovery/V2, for: "1.5.0" } + - name: tls + specFile: fixtures/tls.yaml # external custom resource + requires: + - { name: ContainerImage } +``` + +```go +// buildUnit is the same function you would set as Config.Build: it copies the +// loaded spec (shared across the sweep), applies the version, builds the +// resource, and wraps it as a Unit. +func buildUnit(version string, spec *app.ExampleApp) (goldengen.Unit, error) { + c := spec.DeepCopyObject().(*app.ExampleApp) + c.Spec.Version = version + res, err := resources.NewStatefulSetResource(c) + if err != nil { + return nil, err + } + return goldengen.Resource(res, scheme), nil +} + +cfg, err := goldengen.LoadMatrix( + "testdata/matrix.yaml", + func() *app.ExampleApp { return &app.ExampleApp{} }, + buildUnit, +) +require.NoError(t, err) + +gen := goldengen.New(cfg).WithUpdate(*update) +gen.Run(t) +``` + +`LoadMatrix` does not call `buildUnit` itself. It loads the fixtures and versions from the file and stores `buildUnit` +as the config's `Build` field, then the config runs exactly like one declared in Go: `goldengen.New(cfg)` wraps it, and +`gen.Run` calls `buildUnit(version, spec)` for each version and fixture during the sweep, passing the spec it +unmarshaled from the file. The YAML supplies the data (specs, versions, expectations); `buildUnit` supplies the build +logic. + +`LoadMatrix` errors if a fixture sets both `spec` and `specFile` or neither, if a `for` value is not in `versions`, or +if any spec fails to unmarshal into `T`. diff --git a/plugin/skills/using-primitives/references/primitives.md b/plugin/skills/using-primitives/references/primitives.md new file mode 100644 index 00000000..6443a554 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives.md @@ -0,0 +1,497 @@ +# Primitives Overview + +The `primitives` packages provide reusable, type-safe wrappers for individual Kubernetes objects. A primitive sits +between the [Component layer](component.md) and a raw Kubernetes resource, handling state synchronization, mutation, and +lifecycle so operator authors do not have to. + +This page is the canonical reference for the concepts shared across every primitive: the lifecycle interfaces and the +status values they report, the mutation system, editors and selectors, Server-Side Apply, and cluster-scoped handling. +Individual [primitive pages](#built-in-primitives) link here rather than repeating these explanations, and document only +their kind-specific surface. + +## What a Primitive Is + +A primitive wraps a specific Kubernetes kind (for example `Deployment` or `ConfigMap`) and encapsulates: + +- **A desired-state baseline.** The object you hand the builder, representing the resource's intended shape. +- **A mutation surface.** Typed editors that record changes to the baseline, gated by features or version constraints. +- **Lifecycle integration.** Readiness detection, grace handling, and suspension, depending on the kind. +- **Server-Side Apply.** Desired state is applied via SSA, preserving server defaults and fields owned by other + controllers. + +Every primitive implements the `component.Resource` interface, and may additionally implement one or more +[lifecycle interfaces](#lifecycle-interfaces) to participate in component status aggregation. + +## Primitive Categories + +The framework groups primitives by runtime behavior. The category determines which lifecycle interfaces a primitive +implements and therefore how it contributes to a component's aggregate status. + +```mermaid +flowchart TD + Start([Choosing a primitive category]) --> Q1{Long-running
process?} + Q1 -->|Yes| Workload[Workload
Deployment, StatefulSet, DaemonSet] + Q1 -->|No| Q2{Runs to
completion?} + Q2 -->|Yes| Task[Task
Job] + Q2 -->|No| Q3{Readiness depends
on an external
controller?} + Q3 -->|Yes| Integration[Integration
Service, Ingress, CronJob, HPA] + Q3 -->|No| Static[Static
ConfigMap, Secret, RBAC, PDB] +``` + +### Static + +Examples: `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`. + +The desired state is mostly fixed. These resources are created or updated from configuration but have no complex runtime +convergence, so they are considered `Ready` as soon as they exist. They may optionally expose data through +`DataExtractable`. + +### Workload + +Examples: `Deployment`, `StatefulSet`, `DaemonSet`. + +Long-running processes that require runtime convergence (pods being scheduled and becoming ready). They implement +`Alive`, `Graceful`, and `Suspendable`, supporting health tracking, grace periods, and scaling to zero. + +### Task + +Examples: `Job`. + +Short-lived operations that run to completion (migrations, backups, initialization steps). They implement `Completable` +and `Suspendable`. When suspended, a task is paused if its kind supports it, or deleted and recreated when resumed. + +### Integration + +Examples: `Service`, `Ingress`, `CronJob`, `HPA`. + +Integration points with external or cluster-level systems (networking, load balancers, schedules, autoscaling). Their +readiness depends on controllers the operator does not own, so it may be delayed or partial. They implement +`Operational`, and may also implement `Graceful` or `Suspendable`. + +## Lifecycle Interfaces + +A primitive participates in status aggregation by implementing one or more lifecycle interfaces from +`pkg/component/concepts`. Each interface reports a small, fixed set of status values. The values below are the runtime +**strings** that appear in conditions, not the Go constant identifiers. + +!!! note "This table is the single source of truth" + + Other documentation links here for the interface-to-status mapping. The [component page](component.md) owns how + these values are prioritized and aggregated; the [custom resource guide](custom-resource.md) owns the Go constant + reference for implementers. + +| Interface | Reported status values | Typical kinds | +| ----------------- | -------------------------------------------------------- | ------------------------------------------------ | +| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` | Deployments, StatefulSets, DaemonSets | +| `Graceful` | `Healthy`, `Degraded`, `Down` | Workloads and integrations with slow convergence | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | Any resource with a deactivation behavior | +| `Completable` | `Completed`, `TaskRunning`, `TaskPending`, `TaskFailing` | Jobs and task primitives | +| `Operational` | `Operational`, `OperationPending`, `OperationFailing` | Services, Ingresses, CronJobs | +| `Guardable` | `Blocked` | Resources with runtime preconditions | +| `DataExtractable` | _(no status, side-effecting)_ | Resources that expose post-sync data | + +!!! warning "`Guardable` reports only `Blocked`" + + A guard's other result, `Unblocked`, is an internal control signal that lets the framework proceed. It is never + written to a condition. Only `Blocked` surfaces, with the reason explaining what the resource is waiting for. + +Custom resource wrappers can implement any subset of these interfaces to opt into the corresponding component behaviors. + +## Cluster-Scoped Primitives + +Some Kubernetes kinds are cluster-scoped and have no namespace, for example `ClusterRole`, `ClusterRoleBinding`, and +`PersistentVolume`. + +A primitive for a cluster-scoped kind must call `MarkClusterScoped()` on its `BaseBuilder` during construction. This +inverts the namespace check in `ValidateBase()`: instead of requiring a non-empty namespace, the builder rejects one. + +```text +object namespace cannot be empty +``` + +If you build a cluster-scoped primitive without marking it, `Build()` fails with the error above, because the validator +still expects a namespace. With `MarkClusterScoped()` set, supplying a namespace fails the other way: + +```text +cluster-scoped object must not have a namespace +``` + +A cluster-scoped builder also provides an identity function that omits the namespace segment (for example +`rbac.authorization.k8s.io/v1/ClusterRole/my-role`). At reconcile time the framework detects scope mismatches between +the owner CRD and managed resources using the cluster's REST mapper. See +[Cluster-Scoped Resources](component.md#cluster-scoped-resources) for owner-reference and garbage-collection behavior. + +## Server-Side Apply + +The framework reconciles resources with **Server-Side Apply** (SSA). Each primitive builds its desired state (the +baseline with all active mutations applied) and patches it with `client.Apply`. Only the fields the operator declares +are sent; server-managed defaults, fields set by other controllers (HPAs, sidecar injectors, annotation-based tooling), +and values written by webhooks are left untouched. + +The API server tracks field ownership automatically. The field manager name is derived from the owner and component as +`"{Owner.GetKind()}/{componentName}"`. The framework applies with forced ownership, so it takes control of conflicting +fields from other managers, while fields it does not include stay with their current owners. + +This removes the perpetual-update problem that arises when an operator strips server defaults every cycle, and it lets +primitives coexist with other controllers that touch the same resources. + +## The Mutation System + +Mutations let independent features contribute changes to a primitive's baseline without knowing about each other. A +mutation is a `feature.Mutation[T]`, where `T` is the primitive's mutator type: + +```go +type Mutation[T any] struct { + Name string // unique within the resource; used in gating and error reporting + Feature Gate // optional; nil means apply unconditionally + Mutate func(T) error +} +``` + +Each primitive package defines its own concrete alias (`deployment.Mutation`, `statefulset.Mutation`, and so on) over +this generic type. Register mutations with the builder's variadic `WithMutation`, which preserves the order given: + +```go +b.WithMutation(first, second, third) +``` + +Calling `WithMutation()` with no arguments is a no-op, which composes cleanly with factories that return `[]Mutation`. +Mutation names must be unique within a resource: `Build()` returns an error if two registered mutations share a `Name`, +because the name is what gating and error reporting refer to, and a collision would mask a mis-targeted mutation. The +check compares names only and evaluates no feature gates. + +### Plan and apply + +Mutations do not touch the Kubernetes object directly. Each `Mutate` function records its intent through typed editors, +and the framework replays every recorded edit in a single controlled pass when it calls the mutator's `Apply()`. + +```mermaid +sequenceDiagram + participant Author + participant Builder + participant Mutator + participant Object as Kubernetes object + Author->>Builder: WithMutation(name, feature, mutate) + Note over Builder: stores the mutation, nothing applied yet + Builder->>Mutator: Apply() + loop each enabled feature, in registration order + Mutator->>Mutator: replay recorded edits in fixed category order + Mutator->>Object: write fields + end +``` + +This staging buys three things: changes are recorded before any object is touched, independent features compose without +coupling, and the editors handle presence operations and stable container selection internally instead of leaving slice +surgery to the author. + +### Ordering within a feature + +Features apply in registration order. Within a single feature's apply pass, edits run in a fixed category order so the +result is deterministic regardless of the order methods were called inside `Mutate`. For the pod-workload mutators the +order is: + +1. Object metadata edits +2. Spec edits (for example `EditDeploymentSpec`) +3. Pod-template metadata edits +4. Pod-spec edits +5. Container presence operations (add / remove) +6. Container edits +7. Init-container presence operations +8. Init-container edits + +Within each category, edits run in the order they were recorded. Later features observe the object as modified by all +earlier ones. + +## Boolean-Gated Mutations + +A mutation can be enabled by a runtime condition rather than a version. Use `NewBooleanGate` for a gate whose result is +driven purely by a boolean: + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/feature" + +gate := feature.NewBooleanGate(len(spec.ExtraEnv) > 0) +``` + +`NewBooleanGate(b)` is shorthand for `NewVersionGate("", nil).When(b)`: a gate with no version constraints whose result +depends only on the boolean. It returns a `*VersionGate`, so further conditions can be added with `When`, and every +value passed must be true for the gate to enable. This is the idiomatic way to make a mutation conditional on the +owner's spec, for example applying a user-override mutation only when the user supplied values. + +## Version-Gated Mutations + +To enable a mutation only for certain versions, pass the current version and a slice of `feature.VersionConstraint` to +`NewVersionGate`: + +```go +gate := feature.NewVersionGate(currentVersion, []feature.VersionConstraint{ + semver.MustConstraint(">= 2.0.0"), +}) +``` + +A `VersionGate` is enabled only when every constraint matches `currentVersion` **and** every `When` condition is true. +`nil` constraints are ignored, so version and boolean gating combine freely: + +```go +gate := feature.NewVersionGate(currentVersion, constraints).When(spec.FeatureFlag) +``` + +A common pattern pairs mutually exclusive gates (`>= V` and `< V`) for a field whose shape changed between versions, so +exactly one fires for any given version. + +!!! note "VersionConstraint is an interface" + + `feature.VersionConstraint` is an interface (`Enabled(version string) (bool, error)`). The framework does not ship a + semver implementation; supply one from your version package. The `semver.MustConstraint` call above is illustrative. + +## Mutation Editors + +Editors provide scoped, typed APIs for modifying one part of a resource. A mutator hands an editor to your callback; you +record changes; the framework applies them during the [plan-and-apply pass](#plan-and-apply). Editors fall into a few +groups: + +- **Container editors** (`ContainerEditor`) for env vars, args, resources, probes, and the like, selected by a + [container selector](#container-selectors). +- **Pod-shaping editors** (`PodSpecEditor`, `ObjectMetaEditor`) shared by all pod-workload kinds. +- **Kind-specific spec editors** (`DeploymentSpecEditor`, `ServiceSpecEditor`, `IngressSpecEditor`, and so on), one per + kind. +- **Data editors** (`ConfigMapDataEditor`, `SecretDataEditor`) and **RBAC editors** (`PolicyRulesEditor`, + `BindingSubjectsEditor`). + +Every editor exposes a `.Raw()` method returning a pointer to the underlying Kubernetes struct, for the cases the typed +API does not cover. Using `.Raw()` is safe because the mutation stays scoped to that editor's target and still runs +inside the controlled apply pass. + +Each primitive page documents the editors relevant to its kind. For the full method list of any editor, see the +[Go API reference on pkg.go.dev](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). + +## Container Selectors + +A container selector decides which containers an editor targets, which matters for multi-container pods. The selectors +live in `pkg/mutation/selectors`: + +```go +selectors.AllContainers() // every container in the pod +selectors.ContainerNamed("app") // a single container by name +selectors.ContainersNamed("web", "api") // several containers by name +selectors.ContainerNotNamed("sidecar") // all containers except one +selectors.ContainersNotNamed("agent", "log") // all containers except several +selectors.ContainerAtIndex(0) // the container at a given index +``` + +Within a feature's apply pass, a selector is evaluated against a snapshot of the containers taken at the start of the +container phase, after that same feature's presence operations have run. Matching against the snapshot keeps selection +stable even if an earlier edit renames a container, and it lets a single mutation add a container and then configure it +in the same pass. + +## Workload-Kind-Agnostic Mutations + +`*deployment.Mutator`, `*statefulset.Mutator`, and `*daemonset.Mutator` share the same container, init-container, +pod-spec, pod-template-metadata, object-metadata, environment-variable, and argument editing methods. +`primitives.WorkloadMutator` is the interface covering exactly that shared surface, so one mutation can target any +pod-workload kind. + +Write the emitter once against the interface, then lift it onto each kind's builder with that package's `LiftMutation` +adapter: + +```go +import ( + corev1 "k8s.io/api/core/v1" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/primitives" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/daemonset" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" +) + +// One emitter, written against the shared interface. +func authEnv() feature.Mutation[primitives.WorkloadMutator] { + return feature.Mutation[primitives.WorkloadMutator]{ + Name: "auth-env", + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "AUTH_MODE", Value: "oidc"}) + return nil + }, + } +} + +// Lifted onto each typed builder. +backend.WithMutation(statefulset.LiftMutation(authEnv())) +frontend.WithMutation(deployment.LiftMutation(authEnv())) +agent.WithMutation(daemonset.LiftMutation(authEnv())) +``` + +Each `LiftMutation` returns that package's own `Mutation` type, which is what the builder's `WithMutation` accepts. The +lift bridges the interface-typed emitter to the kind's concrete mutation type, carrying the `Name` and `Feature` gate +through unchanged, so a lifted mutation gates and composes alongside natively typed mutations on the same builder. + +The interface deliberately omits operations that are not common to all three kinds: the per-kind spec editors +(`EditDeploymentSpec`, `EditStatefulSetSpec`, `EditDaemonSetSpec`), `EnsureReplicas` (the DaemonSet mutator has no +replica field), and the StatefulSet-only VolumeClaimTemplate methods. Reach for the concrete mutator type when you need +those. + +## Built-in Primitives + +| Primitive | Category | Documentation | +| ----------------------------------- | ----------- | --------------------------------------------------------- | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/statefulset` | Workload | [statefulset.md](primitives/statefulset.md) | +| `pkg/primitives/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) | +| `pkg/primitives/daemonset` | Workload | [daemonset.md](primitives/daemonset.md) | +| `pkg/primitives/pod` | Workload | [pod.md](primitives/pod.md) | +| `pkg/primitives/job` | Task | [job.md](primitives/job.md) | +| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) | +| `pkg/primitives/role` | Static | [role.md](primitives/role.md) | +| `pkg/primitives/rolebinding` | Static | [rolebinding.md](primitives/rolebinding.md) | +| `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) | +| `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) | +| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | +| `pkg/primitives/serviceaccount` | Static | [serviceaccount.md](primitives/serviceaccount.md) | +| `pkg/primitives/service` | Integration | [service.md](primitives/service.md) | +| `pkg/primitives/pv` | Integration | [pv.md](primitives/pv.md) | +| `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) | +| `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) | +| `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) | +| `pkg/primitives/networkpolicy` | Static | [networkpolicy.md](primitives/networkpolicy.md) | + +## Usage Examples + +The example below builds a frontend `Deployment` for a hypothetical `WebApp` operator, adds a version-gated sidecar +mutation, targets multiple containers, guards on a value extracted from an earlier resource, and registers the result +with a component. + +=== "Building and registering a primitive" + + ```go + import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + ) + + // 1. Baseline: the resource's intended shape. Version-dependent fields + // (such as the image) are left empty and owned by a mutation. + base := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "frontend", + Namespace: owner.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web"}, + {Name: "api"}, + }, + }, + }, + }, + } + + res, err := deployment.NewBuilder(base). + // 2. A mutation: add a sidecar, gated on a version constraint, and + // configure it. The sidecar is added then edited in one pass. + WithMutation(deployment.Mutation{ + Name: "add-proxy-sidecar", + Feature: feature.NewVersionGate(version, proxyConstraints), + Mutate: func(m *deployment.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "proxy", + Image: "envoyproxy/envoy:v1.29", + }) + m.EditContainers(selectors.ContainerNamed("proxy"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "PROXY_ADMIN_PORT", Value: "9901"}) + return nil + }) + return nil + }, + }). + // 3. Target multiple containers in a single edit. + WithMutation(deployment.Mutation{ + Name: "json-logging", + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error { + e.EnsureArg("--log-format=json") + return nil + }) + return nil + }, + }). + // 4. A guard: do not apply until a precondition (here, a value + // extracted from an earlier resource) is satisfied. + WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) { + if apiEndpoint == "" { + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusBlocked, + Reason: "waiting for backend endpoint", + }, nil + } + return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil + }). + Build() + if err != nil { + return nil, err + } + + // 5. Register the primitive with a component. + comp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(res). + Build() + ``` + +=== "Targeting multiple containers" + + ```go + m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error { + e.EnsureArg("--log-format=json") + return nil + }) + ``` + +!!! note "Guards versus prerequisites" + + A [guard](component.md#guards) handles a dependency **within** one component: an earlier resource extracts data after + it is applied, and a later resource's guard checks that data before proceeding. For a dependency **between** + components (the frontend cannot start until the backend is ready), use + [prerequisites](component.md#prerequisites) on the component builder instead. See + [Guards](component.md#guards) for the full behavioral contract. + +## Unstructured Primitives + +| Primitive | Category | Documentation | +| ----------------------------------------- | ----------- | --------------------------------------------- | +| `pkg/primitives/unstructured/static` | Static | [unstructured.md](primitives/unstructured.md) | +| `pkg/primitives/unstructured/workload` | Workload | [unstructured.md](primitives/unstructured.md) | +| `pkg/primitives/unstructured/integration` | Integration | [unstructured.md](primitives/unstructured.md) | +| `pkg/primitives/unstructured/task` | Task | [unstructured.md](primitives/unstructured.md) | + +The unstructured primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type, for +example external CRDs or any object known only at runtime. One variant exists per [category](#primitive-categories), +each implementing the matching lifecycle interfaces. + +Because the framework cannot know the semantics of an unstructured object, it infers no domain-specific defaults. The +builders configure generic safe defaults instead: omit a grace handler and the resource is treated as Healthy; omit +suspension handlers and it reports `Suspended` with a no-op suspend mutation. Only the converge or operational status +handler is required at build time. All variants share a single `Mutator` and use an `UnstructuredContentEditor` for +nested-field edits. See [unstructured.md](primitives/unstructured.md) for details. + +## Implementing a Custom Resource + +When the built-in primitives do not cover your kind, implement a custom resource wrapper for any Kubernetes object, +including your own CRDs. The framework provides generic building blocks in `pkg/generic` that handle reconciliation +mechanics, mutation sequencing, and suspension, so you supply only the type-specific logic. + +See the [Custom Resource Implementation Guide](custom-resource.md) for a complete walkthrough covering mutator design, +status handlers, builders, and component registration. diff --git a/plugin/skills/using-primitives/references/primitives/clusterrole.md b/plugin/skills/using-primitives/references/primitives/clusterrole.md new file mode 100644 index 00000000..19657583 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/clusterrole.md @@ -0,0 +1,263 @@ +# ClusterRole Primitive + +The `clusterrole` primitive wraps a Kubernetes `ClusterRole` and manages RBAC policy rules, aggregation rules, and +object metadata within the component lifecycle. + +!!! warning "Ownership limitation for namespaced owners" + + When a namespaced owner manages a cluster-scoped resource such as a `ClusterRole`, the framework cannot set a + controller owner reference (the scopes are incompatible). The owner reference is skipped and the skip is logged. + The `ClusterRole` is **not** garbage-collected when the owner is deleted. Manage its lifecycle explicitly (for + example with a finalizer on the owner) or use a cluster-scoped owner if automatic cleanup is required. See + [Cluster-Scoped Resources](../component.md#cluster-scoped-resources) for the full behavior. + +## Capabilities + +| Capability | Interfaces / detail | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension | +| **Mutation** | `PolicyRulesEditor` for `.rules`; `SetAggregationRule` for `.aggregationRule`; `ObjectMetaEditor` for labels and annotations | +| **Cluster-scoped** | `MarkClusterScoped()` called during construction; `Build()` rejects a non-empty namespace | +| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) | +| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. For +cluster-scoped builder behavior, see [Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives). + +## Building a ClusterRole Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrole" + +base := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-operator-role", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, +} + +resource, err := clusterrole.NewBuilder(base). + WithMutation(CRDAccessMutation(owner.Spec.Version, owner.Spec.ManageCRDs)). + Build() +``` + +`Build()` returns an error if `Name` is empty or if `Namespace` is non-empty. The constructor calls +`MarkClusterScoped()` internally, so you do not need to call it manually. + +Identity format: `rbac.authorization.k8s.io/v1/ClusterRole/`. + +## Mutations + +Each mutation is a named `clusterrole.Mutation` that receives a `*Mutator` and records edit intent through typed +editors. See [The Mutation System](../primitives.md#the-mutation-system) for the full model. + +```go +func CRDAccessMutation(version string, manageCRDs bool) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "crd-access", + Feature: feature.NewVersionGate(version, nil).When(manageCRDs), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} +``` + +For boolean conditions, chain `.When()` on the gate. See +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in this fixed category order regardless of the call order: + +| Step | Category | What it affects | +| ---- | ---------------- | ----------------------------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ClusterRole` | +| 2 | Rules edits | `.rules`: `EditRules`, `AddRule` | +| 3 | Aggregation rule | `.aggregationRule`: `SetAggregationRule` (last call wins within each feature) | + +Within each category, edits apply in registration order. Later features observe the object as modified by all earlier +ones. + +## Relevant Editors + +### PolicyRulesEditor + +The primary API for modifying `.rules`. Use `m.EditRules` for full control. See +[Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +#### AddRule + +`AddRule` appends a `PolicyRule` to the rules slice: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil +}) +``` + +#### RemoveRuleByIndex + +`RemoveRuleByIndex` removes the rule at the given index. No-op if the index is out of bounds: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.RemoveRuleByIndex(0) // remove the first rule + return nil +}) +``` + +#### Clear + +`Clear` removes all rules: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.Clear() + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.PolicyRule` for free-form editing: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + raw := e.Raw() + *raw = append(*raw, customRules...) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("managed-by", "my-operator") + return nil +}) +``` + +## Convenience Methods + +The `*Mutator` exposes a direct convenience method for the most common `.rules` operation: + +| Method | Equivalent to | +| --------------- | ------------------------------- | +| `AddRule(rule)` | `EditRules` → `e.AddRule(rule)` | + +Use `AddRule` for simple, single-rule mutations. Use `EditRules` when you need multiple operations or raw access in a +single edit block. + +## SetAggregationRule + +`SetAggregationRule` sets the ClusterRole's `.aggregationRule` field. An aggregation rule causes the API server to +combine rules from ClusterRoles whose labels match the provided selectors, instead of using `.rules` directly: + +```go +m.SetAggregationRule(&rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"rbac.example.com/aggregate-to-admin": "true"}}, + }, +}) +``` + +Pass `nil` to clear the aggregation rule. Within a single feature, the last `SetAggregationRule` call wins. + +!!! note + + The Kubernetes API ignores `.rules` when `.aggregationRule` is set. The two approaches are mutually exclusive. + +## Data Extraction + +`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled ClusterRole: + +```go +resource, err := clusterrole.NewBuilder(base). + WithDataExtractor(func(cr rbacv1.ClusterRole) error { + sharedState.ClusterRoleName = cr.Name + return nil + }). + Build() +``` + +## Full Example + +```go +func CoreRulesMutation() clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "core-rules", + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"pods", "services", "configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} + +func CRDAccessMutation(version string, manageCRDs bool) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "crd-access", + Feature: feature.NewVersionGate(version, nil).When(manageCRDs), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} + +resource, err := clusterrole.NewBuilder(base). + WithMutation(CoreRulesMutation()). + WithMutation(CRDAccessMutation(owner.Spec.Version, owner.Spec.ManageCRDs)). + Build() +``` + +When `ManageCRDs` is true, the final rules include both core and CRD access rules. When false, only the core rules are +written. Neither mutation needs to know about the other. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use +`feature.NewVersionGate(version, constraints)` when version gating is needed, and chain `.When(bool)` for boolean +conditions. + +**Use `AddRule` for composable permissions.** `AddRule` lets each feature contribute rules without knowing about others. +Using `SetRules` (via `Raw`) in multiple features means the last write wins; use that only when full replacement is the +intended semantics. + +**Use `SetAggregationRule` for composite roles.** When you want the API server to aggregate rules from multiple +ClusterRoles via label selectors, call `SetAggregationRule` instead of managing `.rules` directly. Do not mix both +approaches on the same role. + +**Cluster-scoped resources are not garbage-collected by namespaced owners.** A namespaced custom resource cannot own a +cluster-scoped `ClusterRole`. Handle deletion explicitly, for example by adding a finalizer on the owner that deletes +the `ClusterRole` before the owner is removed. diff --git a/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md b/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md new file mode 100644 index 00000000..bc549d36 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md @@ -0,0 +1,243 @@ +# ClusterRoleBinding Primitive + +The `clusterrolebinding` primitive wraps a Kubernetes `ClusterRoleBinding` and manages the subjects list and object +metadata within the component lifecycle. + +!!! warning "Ownership limitation for namespaced owners" + + When a namespaced owner manages a cluster-scoped resource such as a `ClusterRoleBinding`, the framework cannot set a + controller owner reference (the scopes are incompatible). The owner reference is skipped and the skip is logged. + The `ClusterRoleBinding` is **not** garbage-collected when the owner is deleted. Manage its lifecycle explicitly (for example with a finalizer on the owner) or use a cluster-scoped owner if automatic cleanup is required. See + [Cluster-Scoped Resources](../component.md#cluster-scoped-resources) for the full behavior. + +## Capabilities + +| Capability | Interfaces / detail | +| --------------------- | ----------------------------------------------------------------------------------------- | +| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension | +| **Mutation** | `BindingSubjectsEditor` for `.subjects`; `ObjectMetaEditor` for labels and annotations | +| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation | +| **Cluster-scoped** | `MarkClusterScoped()` called during construction; `Build()` rejects a non-empty namespace | +| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) | +| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. For +cluster-scoped builder behavior, see [Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives). + +## Building a ClusterRoleBinding Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding" + +base := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-cluster-admin", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "cluster-admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "app-sa", + Namespace: "default", + }, + }, +} + +resource, err := clusterrolebinding.NewBuilder(base). + WithMutation(ExtraSubjectMutation(owner.Spec.Version, owner.Spec.EnableExtra)). + Build() +``` + +`Build()` returns an error if `Name` is empty or if `Namespace` is non-empty. The constructor calls +`MarkClusterScoped()` internally, so you do not need to call it manually. + +`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not +modifiable via the mutation API. To change a `roleRef`, delete and recreate the ClusterRoleBinding. + +Identity format: `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. + +## Mutations + +Each mutation is a named `clusterrolebinding.Mutation` that receives a `*Mutator` and records edit intent through typed +editors. See [The Mutation System](../primitives.md#the-mutation-system) for the full model. + +```go +func ExtraSubjectMutation(version string, enabled bool) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "extra-subject", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("extra-sa", "monitoring") + return nil + }) + return nil + }, + } +} +``` + +For boolean conditions, chain `.When()` on the gate. See +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in this fixed category order regardless of the call order: + +| Step | Category | What it affects | +| ---- | -------------- | -------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ClusterRoleBinding` | +| 2 | Subject edits | `.subjects` entries via `BindingSubjectsEditor` | + +Within each category, edits apply in registration order. Later features observe the object as modified by all earlier +ones. + +## Relevant Editors + +### BindingSubjectsEditor + +The primary API for modifying `.subjects`. Use `m.EditSubjects` for full control. See +[Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("my-sa", "default") + e.RemoveSubject("User", "old-user", "") + return nil +}) +``` + +#### EnsureSubject + +`EnsureSubject` upserts a subject by the combination of `Kind`, `Name`, and `Namespace`. If a matching subject already +exists it is replaced; otherwise the new subject is appended. + +```go +e.EnsureSubject(rbacv1.Subject{ + Kind: "Group", + Name: "developers", + APIGroup: "rbac.authorization.k8s.io", +}) +``` + +#### EnsureServiceAccount + +Convenience wrapper that ensures a `ServiceAccount` subject with the given name and namespace exists: + +```go +e.EnsureServiceAccount("app-sa", "production") +``` + +#### RemoveSubject and RemoveServiceAccount + +`RemoveSubject` removes a subject identified by kind, name, and namespace. `RemoveServiceAccount` is a convenience +wrapper for removing `ServiceAccount` subjects: + +```go +e.RemoveSubject("User", "old-user", "") +e.RemoveServiceAccount("deprecated-sa", "default") +``` + +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` for free-form editing: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + raw := e.Raw() + for i := range *raw { + if (*raw)[i].Kind == "ServiceAccount" { + (*raw)[i].Namespace = "updated-namespace" + } + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator") + e.EnsureAnnotation("description", "cluster-wide binding") + return nil +}) +``` + +## Data Extraction + +`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled +ClusterRoleBinding. Use it to surface binding metadata to other resources: + +```go +resource, err := clusterrolebinding.NewBuilder(base). + WithDataExtractor(func(crb rbacv1.ClusterRoleBinding) error { + sharedState.ClusterRoleBindingName = crb.Name + return nil + }). + Build() +``` + +## Full Example + +```go +func BaseSubjectMutation(version, saName, saNamespace string) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "base-subject", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount(saName, saNamespace) + return nil + }) + return nil + }, + } +} + +func ExtraSubjectMutation(version string, enabled bool) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "extra-subject", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("extra-sa", "monitoring") + return nil + }) + return nil + }, + } +} + +resource, err := clusterrolebinding.NewBuilder(base). + WithMutation(BaseSubjectMutation(owner.Spec.Version, "app-sa", owner.Namespace)). + WithMutation(ExtraSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)). + Build() +``` + +When `EnableMonitoring` is true, the binding's subjects list contains both the base service account and the monitoring +service account. When false, only the base subject is present. + +## Guidance + +**Set `roleRef` on the base object, not via mutations.** Kubernetes makes `roleRef` immutable after creation. To change +a `roleRef`, delete and recreate the ClusterRoleBinding. + +**Use `EnsureServiceAccount` as a shortcut for the most common subject type.** It sets `Kind`, `Name`, and `Namespace` +in one call and is equivalent to `EnsureSubject` with a `ServiceAccount` kind. + +**Cluster-scoped resources are not garbage-collected by namespaced owners.** A namespaced custom resource cannot own a +cluster-scoped `ClusterRoleBinding`. Handle deletion explicitly, for example by adding a finalizer on the owner that +deletes the `ClusterRoleBinding` before the owner is removed. + +**Cluster-scoped bindings have no namespace.** The identity format is +`rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. Leave `ObjectMeta.Namespace` empty; `Build()` rejects a +non-empty namespace. diff --git a/plugin/skills/using-primitives/references/primitives/configmap.md b/plugin/skills/using-primitives/references/primitives/configmap.md new file mode 100644 index 00000000..0b38fde1 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/configmap.md @@ -0,0 +1,327 @@ +# ConfigMap Primitive + +The `configmap` primitive wraps a Kubernetes `ConfigMap` and integrates with the component lifecycle as a Static +resource, providing a structured mutation API for managing `.data` entries and object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ------------------------------------------------------------------------------------------------ | +| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for `.data` entries and object metadata, with a `Raw()` escape hatch | +| **MergeYAML** | Deep-merges YAML patches into individual `.data` entries; composable across independent features | +| **DataExtractable** | Reads values back from the reconciled ConfigMap after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. + +## Building a ConfigMap Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + +base := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-config", + Namespace: owner.Namespace, + }, + Data: map[string]string{ + "config.yaml": "log_level: info\n", + }, +} + +resource, err := configmap.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example using the `SetEntry` convenience method: + +```go +func MyFeatureMutation(version string) configmap.Mutation { + return configmap.Mutation{ + Name: "my-feature", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *configmap.Mutator) error { + m.SetEntry("feature-flag", "enabled") + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | -------------- | -------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ConfigMap` | +| 2 | Data edits | `.data` entries: Set, Remove, MergeYAML, Raw | + +Within each category, edits run in registration order. Later features observe the ConfigMap as modified by all earlier +ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### ConfigMapDataEditor + +The primary API for modifying `.data` and `.binaryData` entries. Use `m.EditData` for full control: + +```go +m.EditData(func(e *editors.ConfigMapDataEditor) error { + e.Set("key", "value") + e.Remove("stale-key") + return e.MergeYAML("config.yaml", "debug: true\n") +}) +``` + +#### Set and Remove + +`Set` adds or overwrites a `.data` key. `Remove` deletes a `.data` key; it is a no-op if the key is absent. + +```go +m.EditData(func(e *editors.ConfigMapDataEditor) error { + e.Set("mode", "production") + e.Remove("dev-only-flag") + return nil +}) +``` + +#### SetBinary and RemoveBinary + +`SetBinary` sets a raw byte slice in `.binaryData`. `RemoveBinary` deletes a `.binaryData` key; it is a no-op if the key +is absent. Format and encode the value before passing it in. + +```go +m.EditData(func(e *editors.ConfigMapDataEditor) error { + e.SetBinary("cert.pem", certBytes) + e.RemoveBinary("old-cert.pem") + return nil +}) +``` + +#### MergeYAML + +`MergeYAML` deep-merges a YAML patch string into the existing value at a key in `.data`. Merge semantics: + +- If both the existing value and the patch are YAML mappings, their keys are merged recursively. Keys present only in + the base are preserved, keys present only in the patch are added, and keys present in both are resolved by applying + `MergeYAML` recursively. +- For all other types (scalars, sequences, mixed), the patch value wins. +- If the key does not yet exist, the patch is written as-is. + +This makes it suitable for composing contributions from independent features without each needing to know about the +others: + +```go +// Feature A contributes logging config. +m.EditData(func(e *editors.ConfigMapDataEditor) error { + return e.MergeYAML("app.yaml", "logging:\n level: info\n") +}) + +// Feature B independently contributes tracing config into the same file. +m.EditData(func(e *editors.ConfigMapDataEditor) error { + return e.MergeYAML("app.yaml", "tracing:\n enabled: true\n") +}) +// Result: app.yaml contains both logging and tracing sections. +``` + +#### Raw Escape Hatches + +`Raw()` returns the underlying `map[string]string` for `.data`. `RawBinary()` returns the underlying `map[string][]byte` +for `.binaryData`. Both give direct access for free-form editing: + +```go +m.EditData(func(e *editors.ConfigMapDataEditor) error { + raw := e.Raw() + for k, v := range externalDefaults { + if _, exists := raw[k]; !exists { + raw[k] = v + } + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("checksum/config", configHash) + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes convenience wrappers for the most common `.data` operations: + +| Method | Equivalent to | +| ----------------------- | -------------------------------------- | +| `SetEntry(key, value)` | `EditData` → `e.Set(key, value)` | +| `RemoveEntry(key)` | `EditData` → `e.Remove(key)` | +| `MergeYAML(key, patch)` | `EditData` → `e.MergeYAML(key, patch)` | + +Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a +single edit block. + +## Data Hash + +Two utilities compute a stable SHA-256 hash of a ConfigMap's `.data` and `.binaryData` fields. A common use is to +annotate a Deployment's pod template with this hash so that a configuration change triggers a rolling restart. + +### DataHash + +`DataHash` hashes a ConfigMap value you already have, for example one read from the cluster: + +```go +hash, err := configmap.DataHash(cm) +``` + +The hash is derived from the canonical JSON encoding of `.data` and `.binaryData` with map keys sorted alphabetically, +so it is deterministic regardless of insertion order. Metadata fields are excluded. + +### Resource.DesiredHash + +`DesiredHash` computes the hash of what the operator _will write_ (the base object with all registered mutations +applied) without performing a cluster read and without a second reconcile cycle: + +```go +cmResource, err := configmap.NewBuilder(base). + WithMutation(BaseConfigMutation(owner.Spec.Version)). + WithMutation(TracingMutation(owner.Spec.EnableTracing)). + Build() + +hash, err := cmResource.DesiredHash() +``` + +The hash covers only operator-controlled fields. + +### Annotating a Deployment pod template (single-pass pattern) + +Build the ConfigMap resource first, compute the hash, then pass it into the Deployment resource factory. Both resources +are registered with the same component, so the ConfigMap is reconciled first and the Deployment sees the correct hash on +every cycle. + +`DesiredHash` is defined on `*configmap.Resource`, not on the `component.Resource` interface, so keep the concrete type +when you need to call it: + +```go +cmResource, err := configmap.NewBuilder(base). + WithMutation(features.BaseConfigMutation(owner.Spec.Version)). + WithMutation(features.TracingMutation(owner.Spec.Version, owner.Spec.EnableTracing)). + Build() +if err != nil { + return err +} + +hash, err := cmResource.DesiredHash() +if err != nil { + return err +} + +deployResource, err := resources.NewDeploymentResource(owner, hash) +if err != nil { + return err +} + +comp, err := component.NewComponentBuilder(). + WithResource(cmResource). // reconciled first + WithResource(deployResource). + Build() +``` + +```go +// In NewDeploymentResource, use the hash in a mutation: +func ChecksumAnnotationMutation(version, configHash string) deployment.Mutation { + return deployment.Mutation{ + Name: "config-checksum", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("checksum/config", configHash) + return nil + }) + return nil + }, + } +} +``` + +When the ConfigMap mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the +same reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. + +## Full Example + +```go +func BaseConfigMutation(version string) configmap.Mutation { + return configmap.Mutation{ + Name: "base-config", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *configmap.Mutator) error { + m.EditData(func(e *editors.ConfigMapDataEditor) error { + return e.MergeYAML("app.yaml", ` +server: + port: 8080 + timeout: 30s +`) + }) + return nil + }, + } +} + +func MetricsFeatureMutation(version string, enabled bool) configmap.Mutation { + return configmap.Mutation{ + Name: "metrics-feature", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *configmap.Mutator) error { + m.EditData(func(e *editors.ConfigMapDataEditor) error { + return e.MergeYAML("app.yaml", ` +metrics: + enabled: true + port: 9090 +`) + }) + return nil + }, + } +} + +resource, err := configmap.NewBuilder(base). + WithMutation(BaseConfigMutation(owner.Spec.Version)). + WithMutation(MetricsFeatureMutation(owner.Spec.Version, owner.Spec.MetricsEnabled)). + Build() +``` + +When `MetricsEnabled` is true, the final `app.yaml` entry contains the merged result of both patches. When false, only +the base config is written. Neither mutation needs to know about the other. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions. + +**Use `MergeYAML` for composable config files.** When multiple features contribute to the same YAML entry, `MergeYAML` +lets each contribute its section independently. Using `SetEntry` in multiple features for the same key means the last +registration wins; only use that when replacement is the intended semantics. + +**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. + +**Use `DesiredHash` for rolling restarts.** Build the ConfigMap resource, call `DesiredHash()`, and stamp the result as +a pod-template annotation on the Deployment in the same reconcile pass. No extra cluster reads are required. diff --git a/plugin/skills/using-primitives/references/primitives/cronjob.md b/plugin/skills/using-primitives/references/primitives/cronjob.md new file mode 100644 index 00000000..8dab0ea0 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/cronjob.md @@ -0,0 +1,304 @@ +# CronJob Primitive + +The `cronjob` primitive wraps a Kubernetes `CronJob` and provides operational status tracking, grace handling, +suspension, and a typed mutation API for managing the schedule, job template, pod spec, and containers as part of the +component lifecycle. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | ----------------------------------------------------- | +| `Operational` | `Operational`, `OperationPending`, `OperationFailing` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a CronJob Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" + +base := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-cleanup", + Namespace: owner.Namespace, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + {Name: "cleanup", Image: "cleanup-tool:latest"}, + }, + }, + }, + }, + }, + }, +} + +resource, err := cronjob.NewBuilder(base). + WithMutation(MyScheduleMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `cronjob.Mutation` that receives a `*cronjob.Mutator` and records edits through typed editors. + +```go +func ScheduleMutation(version string) cronjob.Mutation { + return cronjob.Mutation{ + Name: "schedule", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *cronjob.Mutator) error { + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 */6 * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + return nil + }) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +For all primitives, desired state is reconciled via [Server-Side Apply](../primitives.md#server-side-apply). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | --------------------------- | --------------------------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `CronJob` object | +| 2 | CronJobSpec edits | Schedule, concurrency policy, time zone, history limits | +| 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL | +| 4 | Pod template metadata edits | Labels and annotations on the pod template | +| 5 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 6 | Regular container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.containers` | +| 7 | Regular container edits | Env vars, args, resources (snapshot taken after step 6) | +| 8 | Init container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.initContainers` | +| 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) | + +Container edits (steps 7 and 9) are evaluated against a snapshot taken _after_ presence operations in the same feature. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### CronJobSpecEditor + +Controls CronJob-level settings via `m.EditCronJobSpec`. + +Available methods: `SetSchedule`, `SetConcurrencyPolicy`, `SetStartingDeadlineSeconds`, `SetSuccessfulJobsHistoryLimit`, +`SetFailedJobsHistoryLimit`, `SetTimeZone`, `Raw`. + +```go +m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 2 * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + e.SetFailedJobsHistoryLimit(1) + return nil +}) +``` + +!!! note "No typed helper for `spec.suspend`" + + `spec.suspend` is not exposed by the typed API. Use `Raw()` if you need to set it directly, but prefer the + framework's suspend mechanism instead. + +### JobSpecEditor + +Controls the embedded job template spec via `m.EditJobSpec`. + +Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, +`SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetTTLSecondsAfterFinished(3600) + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("cleanup-sa") + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DRY_RUN", Value: "false"}) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` for the `CronJob` itself or `m.EditPodTemplateMetadata` for +the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Workload-Kind-Agnostic Mutations + +The `cronjob.Mutator` does not implement `primitives.WorkloadMutator` and therefore does not have a `LiftMutation` +adapter. The `WorkloadMutator` interface targets Deployment, StatefulSet, and DaemonSet. Write shared mutation logic as +a plain function accepting `*cronjob.Mutator` and call it directly. + +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern. + +## Operational Status + +`DefaultOperationalStatusHandler` always reports `Operational`. A CronJob is a passive scheduler: once it exists in the +cluster it is functioning correctly regardless of whether it has fired yet. Schedule intervals may be longer than the +component's grace period, so treating a never-scheduled CronJob as pending would produce false degradation signals. +Failures are reported on the spawned Job resources, not on the CronJob itself. + +Override with `WithCustomOperationalStatus` if you need visibility into whether the CronJob has executed: + +```go +cronjob.NewBuilder(base). + WithCustomOperationalStatus(func(_ concepts.ConvergingOperation, cj *batchv1.CronJob) (concepts.OperationalStatusWithReason, error) { + if cj.Status.LastScheduleTime == nil { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "CronJob has not fired yet", + }, nil + } + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "CronJob has fired at least once", + }, nil + }) +``` + +## Grace Status + +`DefaultGraceStatusHandler` always reports `Healthy`. A CronJob is considered healthy once it exists and is not +suspended. Override with `WithCustomGraceStatus` if your CronJob has specific health requirements. + +## Suspension + +When the component is suspended, the CronJob sets `spec.suspend = true`, preventing new Jobs from being created. +Existing active jobs continue running. + +| Status | Condition | +| ------------ | ---------------------------------------------------- | +| `Suspending` | `spec.suspend == true` but active jobs still running | +| `Suspended` | `spec.suspend == true` and no active jobs | +| `Suspending` | Waiting for the suspend flag to be applied | + +The CronJob is never deleted on suspend (`DefaultDeleteOnSuspendHandler` returns `false`). On unsuspend, the desired +state without `spec.suspend = true` is reapplied via Server-Side Apply, and the CronJob resumes scheduling. + +## Full Example + +```go +func CleanupMutation(version string, schedule string) cronjob.Mutation { + return cronjob.Mutation{ + Name: "cleanup-schedule", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *cronjob.Mutator) error { + // CronJob spec: schedule and concurrency + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule(schedule) + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + e.SetFailedJobsHistoryLimit(3) + e.SetSuccessfulJobsHistoryLimit(1) + return nil + }) + + // Job spec: backoff and TTL + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(2) + e.SetTTLSecondsAfterFinished(3600) + return nil + }) + + // Pod spec: service account + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("cleanup-sa") + return nil + }) + + // Container: configuration + m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DRY_RUN", Value: "false"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("200m")) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi")) + return nil + }) + + return nil + }, + } +} +``` + +The four nesting levels mirror the object structure: `CronJobSpec` -> `JobSpec` -> `PodSpec` -> `ContainerEditor`. Each +editor targets one level of that nesting. + +## Guidance + +**CronJobs are passive schedulers.** They do not run actively; the CronJob controller creates Job objects on schedule. +Model health around the spawned Jobs, not the CronJob resource itself. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Set `RestartPolicy` in the baseline.** Kubernetes requires `spec.jobTemplate.spec.template.spec.restartPolicy` to be +`OnFailure` or `Never`. Set it in the desired object passed to `NewBuilder`. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/plugin/skills/using-primitives/references/primitives/daemonset.md b/plugin/skills/using-primitives/references/primitives/daemonset.md new file mode 100644 index 00000000..b2abf38e --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/daemonset.md @@ -0,0 +1,286 @@ +# DaemonSet Primitive + +The `daemonset` primitive wraps a Kubernetes `DaemonSet` and provides health tracking, suspension, and a typed mutation +API for managing pod spec and containers as part of the component lifecycle. A DaemonSet runs one pod per qualifying +node. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | ------------------------------------------------------- | +| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a DaemonSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/daemonset" + +base := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "log-collector", + Namespace: owner.Namespace, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "log-collector"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "log-collector"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "collector"}, + }, + }, + }, + }, +} + +resource, err := daemonset.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `daemonset.Mutation` that receives a `*daemonset.Mutator` and records edits through typed +editors. + +```go +func MonitoringMutation(version string, enabled bool) daemonset.Mutation { + return daemonset.Mutation{ + Name: "monitoring", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *daemonset.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "metrics-exporter", + Image: "prom/node-exporter:v1.8.0", + }) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `DaemonSet` object | +| 2 | DaemonSetSpec edits | Update strategy, min ready seconds, revision history limit | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### DaemonSetSpecEditor + +Controls DaemonSet-level settings via `m.EditDaemonSetSpec`. + +Available methods: `SetUpdateStrategy`, `SetMinReadySeconds`, `SetRevisionHistoryLimit`, `Raw`. + +```go +m.EditDaemonSetSpec(func(e *editors.DaemonSetSpecEditor) error { + e.SetMinReadySeconds(30) + e.SetRevisionHistoryLimit(5) + return nil +}) +``` + +Use `Raw()` for fields the typed API does not cover: + +```go +m.EditDaemonSetSpec(func(e *editors.DaemonSetSpecEditor) error { + e.Raw().UpdateStrategy = appsv1.DaemonSetUpdateStrategy{ + Type: appsv1.RollingUpdateDaemonSetStrategyType, + } + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("log-collector-sa") + e.EnsureVolume(corev1.Volume{ + Name: "varlog", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{Path: "/var/log"}, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("collector"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("200m")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` for the `DaemonSet` itself or `m.EditPodTemplateMetadata` +for the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +!!! note "No `EnsureReplicas` on DaemonSet" + + DaemonSets have no replicas field. Use node selectors, tolerations, and affinities in the pod spec to control which + nodes run the pods. + +## Workload-Kind-Agnostic Mutations + +A mutation written against `primitives.WorkloadMutator` can be applied to a DaemonSet builder using +`daemonset.LiftMutation`. This lets one emitter function target DaemonSets, Deployments, and StatefulSets without +duplicating code. + +```go +agent.WithMutation(daemonset.LiftMutation(sharedAuthMutation())) +``` + +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the full pattern. + +## Suspension + +DaemonSets have no replicas field, so there is no clean in-place pause mechanism. By default, the DaemonSet is +**deleted** when the component is suspended and recreated when unsuspended. + +- `DefaultDeleteOnSuspendHandler` returns `true`. +- `DefaultSuspendMutationHandler` is a no-op (deletion is handled by the framework). +- `DefaultSuspensionStatusHandler` always reports `Suspended` with reason `"DaemonSet deleted on suspend"`. + +Override these handlers via `WithCustomSuspendDeletionDecision`, `WithCustomSuspendMutation`, and +`WithCustomSuspendStatus` if a different suspension strategy is needed. + +## Status Handlers + +### ConvergingStatus + +`DefaultConvergingStatusHandler` considers a DaemonSet ready when `Status.NumberReady >= Status.DesiredNumberScheduled` +and `DesiredNumberScheduled > 0`. When `DesiredNumberScheduled` is zero and the controller has observed the current +generation (`ObservedGeneration >= Generation`), the DaemonSet is considered converged with reason "No nodes match the +DaemonSet node selector". + +### GraceStatus + +`DefaultGraceStatusHandler` categorizes health as: + +| Status | Condition | +| ---------- | ----------------------------------------------------------------------------------------------------------- | +| `Healthy` | `DesiredNumberScheduled == 0` and `ObservedGeneration >= Generation` (no matching nodes is a valid state) | +| `Degraded` | `DesiredNumberScheduled == 0` but controller has not observed the latest generation, or at least one pod is | +| | ready but below desired count | +| `Down` | `DesiredNumberScheduled > 0` and `NumberReady == 0` | + +The `Healthy` status for zero desired pods reflects that having no matching nodes is a valid configuration, not a +failure. The generation check ensures the controller has observed the latest spec before declaring health. + +## Full Example + +```go +func NodeAgentMutation(version string, hostLogPath string) daemonset.Mutation { + return daemonset.Mutation{ + Name: "node-agent", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *daemonset.Mutator) error { + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("node-agent-sa") + e.EnsureVolume(corev1.Volume{ + Name: "host-logs", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{Path: hostLogPath}, + }, + }) + return nil + }) + + m.EditContainers(selectors.ContainerNamed("collector"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_PATH", Value: "/host/logs"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("100m")) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("128Mi")) + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "host-logs", + MountPath: "/host/logs", + ReadOnly: true, + }) + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**DaemonSets are node-scoped.** Unlike Deployments, a DaemonSet runs one pod per qualifying node. Use node selectors, +tolerations, and affinities to control which nodes run the pods. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**DaemonSets are deleted on suspend.** There is no in-place scale-to-zero. Override `WithCustomSuspendDeletionDecision` +if you need the resource to remain in the cluster when the component is suspended. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/plugin/skills/using-primitives/references/primitives/deployment.md b/plugin/skills/using-primitives/references/primitives/deployment.md new file mode 100644 index 00000000..8dbf06b1 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/deployment.md @@ -0,0 +1,275 @@ +# Deployment Primitive + +The `deployment` primitive wraps a Kubernetes `Deployment` and provides health tracking, suspension, and a typed +mutation API for managing replicas, pod spec, and containers as part of the component lifecycle. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | ------------------------------------------------------- | +| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a Deployment Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + +base := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "web-server", + Namespace: owner.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web"}, + }, + }, + }, + }, +} + +resource, err := deployment.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `deployment.Mutation` that receives a `*deployment.Mutator` and records edits through typed +editors. + +```go +func ConfigMutation(version string) deployment.Mutation { + return deployment.Mutation{ + Name: "config", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `Deployment` object | +| 2 | DeploymentSpec edits | Replicas, progress deadline, revision history, etc. | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature. +A single mutation can add a container and then configure it without selector resolution issues. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### DeploymentSpecEditor + +Controls deployment-level settings via `m.EditDeploymentSpec`. + +Available methods: `SetReplicas`, `SetPaused`, `SetMinReadySeconds`, `SetRevisionHistoryLimit`, +`SetProgressDeadlineSeconds`, `Raw`. + +```go +m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error { + e.SetReplicas(3) + e.SetProgressDeadlineSeconds(600) + return nil +}) +``` + +Use `Raw()` for fields the typed API does not cover, such as update strategy: + +```go +m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error { + e.Raw().Strategy = appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + } + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("web-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "app-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("web"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +For fields the typed API does not cover, such as volume mounts, use `Raw()`: + +```go +m.EditContainers(selectors.ContainerNamed("web"), func(e *editors.ContainerEditor) error { + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "config", + MountPath: "/etc/config", + }) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` for the `Deployment` itself or `m.EditPodTemplateMetadata` +for the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("prometheus.io/scrape", "true") + return nil +}) +``` + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureReplicas(n)` | `EditDeploymentSpec` → `SetReplicas(n)` | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Workload-Kind-Agnostic Mutations + +A mutation written against `primitives.WorkloadMutator` can be applied to a Deployment builder using +`deployment.LiftMutation`. This lets one emitter function target Deployments, StatefulSets, and DaemonSets without +duplicating code. + +```go +frontend.WithMutation(deployment.LiftMutation(sharedAuthMutation())) +``` + +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the full pattern. + +## Suspension + +When the component is suspended, the Deployment is scaled to zero replicas. The resource is not deleted. + +- `DefaultSuspendMutationHandler` calls `EnsureReplicas(0)`. +- `DefaultSuspensionStatusHandler` reports `Suspending` while `Status.Replicas > 0`, then `Suspended`. +- `DefaultDeleteOnSuspendHandler` returns `false`. + +Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision` +on the builder. + +## Full Example + +```go +func LoggingSidecarMutation(version string) deployment.Mutation { + return deployment.Mutation{ + Name: "logging-sidecar", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *deployment.Mutator) error { + // Presence operation runs at step 5 + m.EnsureContainer(corev1.Container{ + Name: "logger", + Image: "fluent/fluent-bit:3.0", + }) + + // Container edit runs at step 6 (after presence) + m.EditContainers(selectors.ContainerNamed("logger"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "varlog", + MountPath: "/var/log", + }) + return nil + }) + + // Pod spec edit runs at step 4 (before container presence) + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.EnsureVolume(corev1.Volume{ + Name: "varlog", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + return nil + }) + + return nil + }, + } +} +``` + +Although `EditPodSpec` is called after `EnsureContainer` in the source, it is applied in step 4 (before container +presence in step 5) per the internal ordering. Order your source calls for readability; the framework handles execution +order. + +## Guidance + +**Use a Deployment for stateless long-running workloads.** Deployments manage rolling updates and replica counts but do +not guarantee pod identity or stable network addresses. For stateful workloads requiring stable hostnames or persistent +volumes bound to a specific pod, use a StatefulSet. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so selectors in the +same mutation resolve correctly and reconciliation remains idempotent. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/plugin/skills/using-primitives/references/primitives/hpa.md b/plugin/skills/using-primitives/references/primitives/hpa.md new file mode 100644 index 00000000..1a26473a --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/hpa.md @@ -0,0 +1,350 @@ +# HorizontalPodAutoscaler Primitive + +The `hpa` primitive wraps `autoscaling/v2 HorizontalPodAutoscaler` and integrates it with the component lifecycle as an +Operational, Graceful, and Suspendable resource. + +## Capabilities + +The interfaces below are from [`pkg/component/concepts`](../primitives.md#lifecycle-interfaces). The values in the table +are the runtime strings that appear in conditions. + +| Interface | Reported status values | Notes | +| ----------------- | ----------------------------------------------------- | ------------------------------------------- | +| `Operational` | `Operational`, `OperationPending`, `OperationFailing` | Inspects `ScalingActive` and `AbleToScale` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | Same HPA conditions, evaluated post-grace | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | Delete-on-suspend by default | +| `Guardable` | `Blocked` | Optional runtime precondition | +| `DataExtractable` | _(side-effecting, no status)_ | Read generated fields after each sync cycle | + +## Building an HPA Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa" + +base := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend-hpa", + Namespace: owner.Namespace, + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "backend", + }, + MinReplicas: ptr.To(int32(2)), + MaxReplicas: 10, + }, +} + +resource, err := hpa.NewBuilder(base). + WithMutation(CPUScalingMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are named functions that receive a `*hpa.Mutator` and record edit intent through typed editors. For a full +explanation of the mutation system, boolean-gated mutations, and version-gated mutations see +[The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A concise version-gated example: + +```go +var newScalingConstraint = semver.MustConstraint(">= 2.0.0") + +func AggressiveScalingMutation(version string, enabled bool) hpa.Mutation { + return hpa.Mutation{ + Name: "aggressive-scaling", + Feature: feature.NewVersionGate(version, []feature.VersionConstraint{newScalingConstraint}). + When(enabled), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(20) + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(60)), + }, + }) + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits execute in a fixed category order regardless of the order they are recorded: + +| Step | Category | What it affects | +| ---- | -------------- | -------------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `HorizontalPodAutoscaler` object | +| 2 | HPA spec edits | Scale target ref, min/max replicas, metrics, behavior | + +Features apply in registration order. Later features observe the HPA as modified by all earlier ones. + +## Relevant Editors + +For the full method list of any editor see the +[Go API reference](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). The +generic concept is explained in [Mutation Editors](../primitives.md#mutation-editors). + +### HPASpecEditor + +Controls the HPA spec via `m.EditHPASpec`. + +Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`, +`SetBehavior`, `Raw`. + +```go +m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMinReplicas(ptr.To(int32(2))) + e.SetMaxReplicas(10) + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(int32(80)), + }, + }, + }) + return nil +}) +``` + +#### EnsureMetric identity rules + +`EnsureMetric` upserts by full metric identity. If a matching entry exists it is replaced; otherwise the metric is +appended. + +| Metric type | Match key | +| ------------------- | --------------------------------------------------------------------------------------------------------- | +| `Resource` | `Resource.Name` (e.g. `cpu`, `memory`) | +| `Pods` | `Pods.Metric.Name` + `Pods.Metric.Selector` (`nil` is a distinct identity) | +| `Object` | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` | +| `ContainerResource` | `ContainerResource.Name` + `ContainerResource.Container` | +| `External` | `External.Metric.Name` + `External.Metric.Selector` (`nil` is a distinct identity) | + +#### RemoveMetric + +`RemoveMetric(type, name)` removes all metrics matching the given type and name. For `ContainerResource` metrics all +container variants of the named resource are removed. For fine-grained removal of a single identity, use `Raw()` and +modify the slice directly. + +#### SetBehavior + +`SetBehavior` sets the autoscaling behavior (stabilization windows, scaling policies). Pass `nil` to remove custom +behavior and revert to Kubernetes defaults. + +```go +m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(300)), + }, + }) + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.Raw().MinReplicas = ptr.To(int32(1)) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Operational Status + +The default handler inspects `Status.Conditions`: + +| Status | Condition | +| ------------------ | ------------------------------------------------------- | +| `Operational` | `ScalingActive` is `True` | +| `OperationPending` | Conditions absent, or `ScalingActive` is `Unknown` | +| `OperationFailing` | `ScalingActive` is `False`, or `AbleToScale` is `False` | + +`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot scale is not healthy +regardless of what the scaling-active condition reports. + +Override with `WithCustomOperationalStatus`: + +```go +hpa.NewBuilder(base). + WithCustomOperationalStatus(func(op concepts.ConvergingOperation, h *autoscalingv2.HorizontalPodAutoscaler) (concepts.OperationalStatusWithReason, error) { + status, err := hpa.DefaultOperationalStatusHandler(op, h) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Grace Status + +The default grace handler applies the same condition inspection after the grace period expires: + +| Status | Condition | +| ---------- | ------------------------------------------------------- | +| `Healthy` | `ScalingActive` is `True` | +| `Degraded` | Conditions absent, or `ScalingActive` is `Unknown` | +| `Down` | `ScalingActive` is `False`, or `AbleToScale` is `False` | + +Override with `WithCustomGraceStatus`: + +```go +hpa.NewBuilder(base). + WithCustomGraceStatus(func(h *autoscalingv2.HorizontalPodAutoscaler) (concepts.GraceStatusWithReason, error) { + status, err := hpa.DefaultGraceStatusHandler(h) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Suspension + +HPA has no native suspend field. The default behavior is **delete on suspend**: the HPA is removed when the component +suspends and recreated on resume. + +The reason this is necessary is the sequencing interaction with the HPA's scale target. When a `Deployment` (or other +workload) is suspended, the framework scales it to zero. A retained HPA would continuously enforce `minReplicas` and +scale the target back up, fighting the suspension. By deleting the HPA first, the target is free to scale down cleanly. +On resume the framework recreates the HPA before bringing the workload back. + +The default suspension status handler reports `Suspended` immediately because the deletion is handled by the framework +and no additional convergence is required. + +Override the deletion decision with `WithCustomSuspendDeletionDecision`: + +```go +hpa.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *autoscalingv2.HorizontalPodAutoscaler) bool { + return false // keep the HPA during suspension + }) +``` + +!!! note "When to keep the HPA" + + Retaining the HPA during suspension is only appropriate when the scale target is managed externally and will not + be present during the component's suspension period. In the normal case where the HPA and its target are both + managed by the same component, use the default delete behavior. + +Override the suspension reason with `WithCustomSuspendStatus` if you need a message that reflects a non-default deletion +decision: + +```go +hpa.NewBuilder(base). + WithCustomSuspendStatus(func(_ *autoscalingv2.HorizontalPodAutoscaler) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "HPA retained; scale target managed externally", + }, nil + }) +``` + +## Full Example + +```go +func AutoscalingMutation(version string) hpa.Mutation { + return hpa.Mutation{ + Name: "autoscaling-config", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMinReplicas(ptr.To(int32(2))) + e.SetMaxReplicas(10) + + // CPU-based scaling target + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(int32(70)), + }, + }, + }) + + // Memory-based scaling target + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(int32(80)), + }, + }, + }) + + // Conservative scale-down to avoid thrashing + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(300)), + }, + }) + + return nil + }) + + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} + +resource, err := hpa.NewBuilder(base). + WithMutation(AutoscalingMutation(owner.Spec.Version)). + Build() +``` + +Although `EditObjectMetadata` is called after `EditHPASpec` in source, metadata edits are applied first per the internal +ordering. Call order inside `Mutate` is for readability only; the framework enforces the correct execution sequence. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use +`feature.NewVersionGate(version, constraints)` for version gating and chain `.When(bool)` for boolean conditions. + +**Register mutations in dependency order.** If mutation B relies on a metric or field set by mutation A, register A +first. + +**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity so repeated calls +with the same identity update rather than duplicate. + +**Delete on suspend is the correct default.** The HPA is removed during component suspension to prevent it from fighting +a scale-to-zero workload. Only override the deletion decision when the scale target is managed externally. + +**Pair the suspension status handler with the deletion decision.** The default suspension reason is intentionally +deletion-agnostic. If you override `WithCustomSuspendDeletionDecision` to retain the HPA, also override +`WithCustomSuspendStatus` so the reason accurately describes what is happening. diff --git a/plugin/skills/using-primitives/references/primitives/ingress.md b/plugin/skills/using-primitives/references/primitives/ingress.md new file mode 100644 index 00000000..6ccd685a --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/ingress.md @@ -0,0 +1,336 @@ +# Ingress Primitive + +The `ingress` primitive wraps a Kubernetes `Ingress` and integrates with the component lifecycle as an Integration, +Graceful, and Suspendable resource, providing a structured mutation API for managing rules, TLS configuration, and +metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ---------------------------------------------------------------------------------------------------- | +| **Operational** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | +| **Graceful** | Reports `Degraded` until a load balancer IP or hostname is assigned, then `Healthy` | +| **Suspendable** | No-op by default. Ingress is left in place; backend returns 502/503 when the backing service is down | +| **DataExtractable** | Reads assigned load balancer addresses after each sync cycle via `WithDataExtractor` | +| **Mutation pipeline** | Typed editors for metadata and Ingress spec (rules, TLS, class name, default backend) | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. + +## Building an Ingress Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/ingress" + +base := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "web-ingress", + Namespace: owner.Namespace, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "app.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +resource, err := ingress.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example gating a TLS mutation on a boolean condition: + +```go +func TLSMutation(version string, enabled bool) ingress.Mutation { + return ingress.Mutation{ + Name: "tls", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"app.example.com"}, + SecretName: "tls-cert", + }) + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | ------------------ | ----------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Ingress` object | +| 2 | Ingress spec edits | Ingress class, default backend, rules, TLS via editor | + +Within each category, edits run in registration order. Later features observe the Ingress as modified by all earlier +ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### IngressSpecEditor + +The primary API for modifying the Ingress spec. Use `m.EditIngressSpec` for full control: + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + e.EnsureRule(networkingv1.IngressRule{Host: "app.example.com"}) + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"app.example.com"}, + SecretName: "tls-cert", + }) + return nil +}) +``` + +#### SetIngressClassName + +Sets the `spec.ingressClassName` field. + +#### SetDefaultBackend + +Sets the default backend for traffic that does not match any rule. + +#### EnsureRule and RemoveRule + +`EnsureRule` upserts a rule by `Host`. If a rule with the same host already exists, it is replaced. `RemoveRule` deletes +the rule with the given host; it is a no-op if no matching rule exists. + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{ + Host: "api.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/v1", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-svc", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + }, + }, + }, + }, + }, + }) + e.RemoveRule("deprecated.example.com") + return nil +}) +``` + +#### EnsureTLS and RemoveTLS + +`EnsureTLS` upserts a TLS entry by the first host in the `Hosts` slice. `RemoveTLS` removes TLS entries whose first host +matches any of the provided hosts. + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"app.example.com", "www.example.com"}, + SecretName: "wildcard-tls", + }) + e.RemoveTLS("old.example.com") + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access: + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + spec := e.Raw() + // direct manipulation + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("nginx.ingress.kubernetes.io/rewrite-target", "/") + return nil +}) +``` + +## Operational Status + +The Ingress primitive implements `concepts.Operational`. The default handler iterates over `Status.LoadBalancer.Ingress` +entries and requires at least one with a non-empty `IP` or `Hostname`: + +| Condition | Status | +| ----------------------------------------- | ------------------ | +| Entry with `IP != ""` or `Hostname != ""` | `Operational` | +| Otherwise | `OperationPending` | + +Override with `WithCustomOperationalStatus` for more complex health checks, such as verifying specific annotations set +by cloud providers. + +## Grace Status + +The default grace status handler inspects `Status.LoadBalancer.Ingress` after the grace period expires: + +| Condition | Status | +| -------------------------------------------------------- | ---------- | +| At least one entry with a non-empty `IP` or `Hostname` | `Healthy` | +| No entries, or all entries lack both `IP` and `Hostname` | `Degraded` | + +Override with `WithCustomGraceStatus`: + +```go +ingress.NewBuilder(base). + WithCustomGraceStatus(func(ing *networkingv1.Ingress) (concepts.GraceStatusWithReason, error) { + status, err := ingress.DefaultGraceStatusHandler(ing) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Suspension + +### Default Behavior + +The default suspension strategy is a no-op: + +- `DefaultDeleteOnSuspendHandler` returns `false`. The Ingress is not deleted. +- `DefaultSuspendMutationHandler` does nothing. The Ingress spec is not modified. +- `DefaultSuspensionStatusHandler` immediately reports `Suspended` with reason + `"Ingress suspended (backend unavailable)"`. + +**Rationale**: deleting an Ingress causes the ingress controller to reload its configuration, which affects the entire +cluster's routing, not just the suspended service. When the backend service is suspended, the Ingress returning 502/503 +is the correct observable behavior. + +### Custom Suspension + +Override any of the suspension handlers via the builder: + +```go +resource, err := ingress.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *networkingv1.Ingress) bool { + return true // delete on suspend + }). + Build() +``` + +## Full Example + +```go +func BaseIngressMutation(version string) ingress.Mutation { + return ingress.Mutation{ + Name: "base-ingress", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + e.EnsureRule(networkingv1.IngressRule{ + Host: "app.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }) + return nil + }) + return nil + }, + } +} + +func TLSMutation(version string, enabled bool) ingress.Mutation { + return ingress.Mutation{ + Name: "tls", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"app.example.com"}, + SecretName: "tls-cert", + }) + return nil + }) + return nil + }, + } +} + +resource, err := ingress.NewBuilder(base). + WithMutation(BaseIngressMutation(owner.Spec.Version)). + WithMutation(TLSMutation(owner.Spec.Version, owner.Spec.TLSEnabled)). + Build() +``` + +When `TLSEnabled` is true, the Ingress includes a TLS block for the host. When false, only the rule is present. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions. + +**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. + +**Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override +to delete-on-suspend if your use case specifically requires removing the Ingress from the cluster during suspension. + +**Use `EnsureRule` for idempotent rule management.** Rules are matched by `Host`; repeated calls with the same host +replace the existing rule rather than duplicating it. diff --git a/plugin/skills/using-primitives/references/primitives/job.md b/plugin/skills/using-primitives/references/primitives/job.md new file mode 100644 index 00000000..2e40c4d1 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/job.md @@ -0,0 +1,250 @@ +# Job Primitive + +The `job` primitive wraps a Kubernetes `Job` and provides completion tracking, suspension, and a typed mutation API for +managing job spec, pod spec, and containers as part of the component lifecycle. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | -------------------------------------------------------- | +| `Completable` | `Completed`, `TaskRunning`, `TaskPending`, `TaskFailing` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a Job Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" + +base := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db-migration", + Namespace: owner.Namespace, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + {Name: "migrate", Image: "migration-tool:latest"}, + }, + }, + }, + }, +} + +resource, err := job.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `job.Mutation` that receives a `*job.Mutator` and records edits through typed editors. + +```go +func MigrationConfigMutation(version string) job.Mutation { + return job.Mutation{ + Name: "migration-config", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *job.Mutator) error { + m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "db:5432"}) + return nil + }) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `Job` object | +| 2 | JobSpec edits | Completions, parallelism, backoff limit, deadline, etc. | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### JobSpecEditor + +Controls job-level settings via `m.EditJobSpec`. + +Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, +`SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetActiveDeadlineSeconds(600) + return nil +}) +``` + +Use `Raw()` for fields the typed API does not cover: + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.Raw().Suspend = ptr.To(true) + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("migration-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "migration-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "db:5432"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` for the `Job` itself or `m.EditPodTemplateMetadata` for the +pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | + +## Workload-Kind-Agnostic Mutations + +The `job.Mutator` does not implement `primitives.WorkloadMutator` and therefore does not have a `LiftMutation` adapter. +The `WorkloadMutator` interface targets Deployment, StatefulSet, and DaemonSet. Write shared mutation logic as a plain +function accepting `*job.Mutator` and call it directly. + +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern. + +## Suspension + +Jobs use the `Completable` lifecycle rather than `Alive`. The suspension behavior differs from Workload primitives: + +- **Default behavior**: `DefaultDeleteOnSuspendHandler` returns `true`, meaning the Job is deleted from the cluster + during suspension. +- **Suspend mutation**: `DefaultSuspendMutationHandler` sets `spec.suspend=true`, which prevents the Job controller from + creating new pods while allowing existing pods to complete. +- **Suspension status**: `DefaultSuspensionStatusHandler` reports `Suspending` if `spec.suspend=true` but active pods + remain, and `Suspended` once `spec.suspend=true` and `status.active==0`. + +Override any handler via `WithCustomSuspendDeletionDecision`, `WithCustomSuspendMutation`, or `WithCustomSuspendStatus` +on the builder: + +```go +resource, err := job.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(j *batchv1.Job) bool { + return false // keep the Job in the cluster when suspended + }). + Build() +``` + +## Full Example + +```go +func MigrationMutation(version string, dbHost string) job.Mutation { + return job.Mutation{ + Name: "migration", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *job.Mutator) error { + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetActiveDeadlineSeconds(300) + return nil + }) + + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("migration-sa") + return nil + }) + + m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: dbHost}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi")) + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension. +Override `WithCustomSuspendDeletionDecision` if you need the Job resource to remain in the cluster. + +**Set `RestartPolicy` in the baseline.** Kubernetes requires `spec.template.spec.restartPolicy` to be `OnFailure` or +`Never` for Jobs. Set it in the desired object passed to `NewBuilder`. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if init containers or sidecar containers are present. diff --git a/plugin/skills/using-primitives/references/primitives/networkpolicy.md b/plugin/skills/using-primitives/references/primitives/networkpolicy.md new file mode 100644 index 00000000..b49dfa9e --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/networkpolicy.md @@ -0,0 +1,251 @@ +# NetworkPolicy Primitive + +The `networkpolicy` primitive wraps a Kubernetes `NetworkPolicy` and integrates with the component lifecycle as a Static +resource, providing a structured mutation API for managing pod selectors, ingress rules, egress rules, and policy types. + +## Capabilities + +| Capability | Detail | +| --------------------- | ---------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for NetworkPolicy spec and object metadata, with a `Raw()` escape hatch | +| **Append semantics** | Ingress and egress rules have no unique key; `AppendIngressRule`/`AppendEgressRule` append unconditionally | +| **DataExtractable** | Reads values back from the reconciled NetworkPolicy after each sync cycle via `WithDataExtractor` | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. + +## Building a NetworkPolicy Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/networkpolicy" + +base := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-netpol", + Namespace: owner.Namespace, + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": owner.Name}, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + networkingv1.PolicyTypeEgress, + }, + }, +} + +resource, err := networkpolicy.NewBuilder(base). + WithMutation(HTTPIngressMutation()). + Build() +``` + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example appending an ingress rule unconditionally: + +```go +func HTTPIngressMutation() networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "http-ingress", + // Feature is nil: mutation is applied unconditionally. + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | -------------- | --------------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `NetworkPolicy` | +| 2 | Spec edits | Pod selector, ingress rules, egress rules, policy types via Raw | + +Within each category, edits run in registration order. Later features observe the NetworkPolicy as modified by all +earlier ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### NetworkPolicySpecEditor + +The primary API for modifying the NetworkPolicy spec. Use `m.EditNetworkPolicySpec` for full control: + +```go +m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.SetPodSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + }) + port := intstr.FromInt32(80) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil +}) +``` + +#### SetPodSelector + +Sets the pod selector that determines which pods the policy applies to within the namespace. An empty `LabelSelector` +matches all pods. + +#### AppendIngressRule and AppendEgressRule + +Append a rule unconditionally. Ingress and egress rules have no unique key, so these methods always append. To replace +the full set of rules atomically, call `RemoveIngressRules` or `RemoveEgressRules` first: + +```go +m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + // Replace all ingress rules atomically. + e.RemoveIngressRules() + e.AppendIngressRule(newRule1) + e.AppendIngressRule(newRule2) + return nil +}) +``` + +#### RemoveIngressRules and RemoveEgressRules + +Clear all ingress or egress rules respectively. Use before `AppendIngressRule`/`AppendEgressRule` to replace the full +set atomically. + +#### SetPolicyTypes + +Sets the policy types. Valid values are `networkingv1.PolicyTypeIngress` and `networkingv1.PolicyTypeEgress`. When +`Egress` is included, egress rules must be set explicitly to permit traffic; an empty list denies all egress. + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*networkingv1.NetworkPolicySpec` for free-form editing: + +```go +m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + raw := e.Raw() + if raw.PodSelector.MatchLabels == nil { + raw.PodSelector.MatchLabels = make(map[string]string) + } + raw.PodSelector.MatchLabels["role"] = "db" + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("policy/managed-by", "operator") + return nil +}) +``` + +## Data Extraction + +Use `WithDataExtractor` to read values from the reconciled NetworkPolicy after each sync cycle. This is useful when +downstream resources need to observe the final applied policy (for example, its resource version or assigned labels): + +```go +var policyName string + +resource, err := networkpolicy.NewBuilder(base). + WithDataExtractor(func(np networkingv1.NetworkPolicy) error { + policyName = np.Name + return nil + }). + Build() +``` + +## Full Example + +```go +func HTTPIngressMutation() networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "http-ingress", + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} + +func MetricsIngressMutation(version string, enabled bool) networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "metrics-ingress", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(9090) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} + +resource, err := networkpolicy.NewBuilder(base). + WithMutation(HTTPIngressMutation()). + WithMutation(MetricsIngressMutation(owner.Spec.Version, owner.Spec.EnableMetrics)). + Build() +``` + +When `EnableMetrics` is true, the final NetworkPolicy has both HTTP and metrics ingress rules. When false, only the HTTP +rule is present. Neither mutation needs to know about the other. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions. + +**Use `RemoveIngressRules`/`RemoveEgressRules` for atomic replacement.** Since rules have no unique key, there is no +upsert-by-key operation. To replace the full set of rules, call `Remove*Rules` first and then add the desired rules. +Alternatively, use `Raw()` for fine-grained manipulation. + +**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. Since +`AppendIngressRule`/`AppendEgressRule` append unconditionally, the order of registration determines the order of rules +in the resulting spec. + +**NetworkPolicy is Static.** It has no operational status, grace status, or suspension behavior. If the policy applies, +the resource is considered ready. diff --git a/plugin/skills/using-primitives/references/primitives/pdb.md b/plugin/skills/using-primitives/references/primitives/pdb.md new file mode 100644 index 00000000..ceab4477 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/pdb.md @@ -0,0 +1,241 @@ +# PodDisruptionBudget Primitive + +The `pdb` primitive wraps `policy/v1 PodDisruptionBudget` and reconciles it to desired state without health tracking or +suspension. + +!!! note "PDB is Static" + + Despite sitting in the "Scaling & Availability" nav group alongside HPA, `PodDisruptionBudget` is a + [Static](../primitives.md#static) primitive. It has no convergence loop, no operational status, and no + suspension behavior. The resource is applied and considered ready once it exists. Readers expecting an + `OperationPending` condition will not find one. + +## Capabilities + +The interfaces below are from [`pkg/component/concepts`](../primitives.md#lifecycle-interfaces). The values in the table +are the runtime strings that appear in conditions. + +| Interface | Reported status values | Notes | +| ----------------- | ----------------------------- | ------------------------------------------- | +| `Guardable` | `Blocked` | Optional runtime precondition | +| `DataExtractable` | _(side-effecting, no status)_ | Read generated fields after each sync cycle | + +## Building a PDB Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pdb" + +minAvailable := intstr.FromString("50%") +base := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend-pdb", + Namespace: owner.Namespace, + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MinAvailable: &minAvailable, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "backend"}, + }, + }, +} + +resource, err := pdb.NewBuilder(base). + WithMutation(DisruptionPolicyMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are named functions that receive a `*pdb.Mutator` and record edit intent through typed editors. For a full +explanation of the mutation system, boolean-gated mutations, and version-gated mutations see +[The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A concise boolean-gated example that switches from percentage-based `MinAvailable` to absolute `MaxUnavailable`: + +```go +func StrictAvailabilityMutation(version string, strict bool) pdb.Mutation { + return pdb.Mutation{ + Name: "strict-availability", + Feature: feature.NewVersionGate(version, nil).When(strict), + Mutate: func(m *pdb.Mutator) error { + m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits execute in a fixed category order regardless of the order they are recorded: + +| Step | Category | What it affects | +| ---- | -------------- | ------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `PodDisruptionBudget` | +| 2 | Spec edits | MinAvailable, MaxUnavailable, selector, eviction policy | + +Features apply in registration order. Later features observe the PDB as modified by all earlier ones. + +## Relevant Editors + +For the full method list of any editor see the +[Go API reference](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). The +generic concept is explained in [Mutation Editors](../primitives.md#mutation-editors). + +### PodDisruptionBudgetSpecEditor + +The primary API for modifying the PDB spec. Access it via `m.EditSpec`. + +Available methods: `SetMinAvailable`, `SetMaxUnavailable`, `ClearMinAvailable`, `ClearMaxUnavailable`, `SetSelector`, +`SetUnhealthyPodEvictionPolicy`, `ClearUnhealthyPodEvictionPolicy`, `Raw`. + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMinAvailable(intstr.FromString("50%")) + e.SetSelector(&metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "backend"}, + }) + return nil +}) +``` + +#### SetMinAvailable and SetMaxUnavailable + +Both methods accept `intstr.IntOrString`, either an integer count or a percentage string (e.g. `"50%"`). These fields +are mutually exclusive in the Kubernetes API. When switching between them, clear the opposing field first: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil +}) +``` + +#### SetUnhealthyPodEvictionPolicy + +Controls how unhealthy pods are handled during eviction. Valid values are `policyv1.IfHealthyBudget` and +`policyv1.AlwaysAllow`. Use `ClearUnhealthyPodEvictionPolicy` to revert to the cluster default: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetUnhealthyPodEvictionPolicy(policyv1.AlwaysAllow) + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.Raw().MinAvailable = &customValue + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Data Extraction + +Use `WithDataExtractor` to read generated or server-populated fields after each sync cycle. The extractor receives a +value copy of the reconciled PDB: + +```go +pdb.NewBuilder(base). + WithDataExtractor(func(p policyv1.PodDisruptionBudget) error { + // p.Status.ExpectedPods is populated by the Kubernetes PDB controller + myComponent.ExpectedPods = p.Status.ExpectedPods + return nil + }) +``` + +## Full Example + +```go +func BasePDBMutation(version string) pdb.Mutation { + return pdb.Mutation{ + Name: "base-pdb", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *pdb.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +func StrictAvailabilityMutation(version string, strict bool) pdb.Mutation { + return pdb.Mutation{ + Name: "strict-availability", + Feature: feature.NewVersionGate(version, nil).When(strict), + Mutate: func(m *pdb.Mutator) error { + m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil + }) + return nil + }, + } +} + +minAvailable := intstr.FromString("50%") +base := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backend-pdb", + Namespace: owner.Namespace, + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MinAvailable: &minAvailable, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "backend"}, + }, + }, +} + +resource, err := pdb.NewBuilder(base). + WithMutation(BasePDBMutation(owner.Spec.Version)). + WithMutation(StrictAvailabilityMutation(owner.Spec.Version, owner.Spec.StrictMode)). + Build() +``` + +When `StrictMode` is true the PDB switches from percentage-based `MinAvailable` to an absolute `MaxUnavailable` of 1. +When false only the base mutation runs and the original `MinAvailable` from the baseline is preserved. Neither mutation +needs to know about the other. + +## Guidance + +**PDB is Static: there is no operational status.** Registering a PDB in a component contributes no `Operational` or +`Alive` condition. It simply exists or does not. If you need lifecycle signals, use the HPA or another Integration +primitive alongside the PDB. + +**`MinAvailable` and `MaxUnavailable` are mutually exclusive.** When switching between them, always clear the opposing +field first. The typed API makes this explicit with `ClearMinAvailable` and `ClearMaxUnavailable`. + +**Selector and workload labels must stay in sync.** The PDB selector must match the pod labels of the workload it +protects. If a mutation renames pods or changes their labels, update the PDB selector in the same release. + +**Register mutations in dependency order.** If mutation B relies on state set by mutation A, register A first. + +**Use data extraction to read `Status` fields.** Fields like `Status.ExpectedPods`, `Status.CurrentHealthy`, and +`Status.DisruptionsAllowed` are populated by the Kubernetes PDB controller after reconciliation. Access them through +`WithDataExtractor` rather than inspecting the baseline object. diff --git a/plugin/skills/using-primitives/references/primitives/pod.md b/plugin/skills/using-primitives/references/primitives/pod.md new file mode 100644 index 00000000..63c855e7 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/pod.md @@ -0,0 +1,225 @@ +# Pod Primitive + +The `pod` primitive wraps a Kubernetes `Pod` and provides health tracking, suspension, and a typed mutation API for +managing pod spec and containers as part of the component lifecycle. + +Most operators do not manage Pod objects directly; higher-level primitives (Deployment, StatefulSet, DaemonSet) own pod +lifecycle. This primitive is provided for operators that explicitly manage individual pods, such as debugging utilities +or node-local agents where a controller-per-pod model applies. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | ---------------------------------------------- | +| `Alive` | `Healthy`, `Creating`, `Updating`, `Failing` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a Pod Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" + +base := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agent", + Namespace: owner.Namespace, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "agent:latest"}, + }, + }, +} + +resource, err := pod.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `pod.Mutation` that receives a `*pod.Mutator` and records edits through typed editors. + +```go +func AgentConfigMutation(version string, debug bool) pod.Mutation { + return pod.Mutation{ + Name: "agent-config", + Feature: feature.NewVersionGate(version, nil).When(debug), + Mutate: func(m *pod.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | -------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `Pod` object | +| 2 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 3 | Regular container presence | Adding or removing containers from `spec.containers` | +| 4 | Regular container edits | Env vars, args, resources (snapshot taken after step 3) | +| 5 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 6 | Init container edits | Env vars, args, resources (snapshot taken after step 5) | + +Container edits (steps 4 and 6) are evaluated against a snapshot taken _after_ presence operations in the same feature. + +!!! warning "Pod spec is largely immutable after creation" + + Most fields in `Pod.spec` are immutable once the pod exists, including the container list, env vars, args, + resources, ports, and probes. Presence operations (`EnsureContainer`, `RemoveContainer`) and most field mutations + are only effective when constructing a new pod or when the pod will be deleted and recreated. The very small set of + fields that can be updated in-place includes container images and, in some configurations, resource requests. Treat + pods as effectively immutable and plan on delete-and-recreate when structural changes are needed. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("agent-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "agent-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +For fields the typed API does not cover, such as volume mounts, use `Raw()`: + +```go +m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error { + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "config", + MountPath: "/etc/agent", + }) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Suspension + +Pods cannot be paused. The default behavior deletes the pod when the component is suspended. + +- `DefaultDeleteOnSuspendHandler` returns `true`. The pod is deleted on suspend. +- `DefaultSuspendMutationHandler` is a no-op; deletion is handled by the framework. +- `DefaultSuspensionStatusHandler` always reports `Suspended` with reason `"Pod deleted on suspend"`. + +## Full Example + +```go +func AgentMutation(version string, cfgName string) pod.Mutation { + return pod.Mutation{ + Name: "agent-setup", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *pod.Mutator) error { + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("agent-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cfgName}, + }, + }, + }) + return nil + }) + + m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "CONFIG_PATH", Value: "/etc/agent/config.yaml"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("200m")) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("128Mi")) + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "config", + MountPath: "/etc/agent", + ReadOnly: true, + }) + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**Pods are effectively immutable after creation.** Plan the full desired state before the pod is created. Changes to +most spec fields require deleting and recreating the pod. Use the Deployment, StatefulSet, or DaemonSet primitives for +workloads that need rolling updates or scaling without manual recreation. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/plugin/skills/using-primitives/references/primitives/pv.md b/plugin/skills/using-primitives/references/primitives/pv.md new file mode 100644 index 00000000..cbc054ce --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/pv.md @@ -0,0 +1,244 @@ +# PersistentVolume Primitive + +The `pv` primitive wraps a Kubernetes `PersistentVolume` and integrates with the component lifecycle as an Integration +and Graceful resource, providing a structured mutation API for managing PV spec fields and object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ------------------------------------------------------------------------------------------------------ | +| **Operational** | Maps PV phase to `Operational`, `OperationPending`, or `OperationFailing` | +| **Graceful** | Available/Bound are `Healthy`; Pending is `Degraded`; Released/Failed are `Down` | +| **Cluster-scoped** | No namespace in the identity or builder. PersistentVolumes are cluster-scoped resources | +| **DataExtractable** | Reads generated or updated values back from the reconciled PersistentVolume after each sync cycle | +| **Mutation pipeline** | Typed editors for PV spec fields and object metadata, with a `Raw()` escape hatch for free-form access | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. For cluster-scoped handling and owner-reference behavior, see +[Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives). + +## Building a PersistentVolume Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pv" + +base := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-volume", + }, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("100Gi"), + }, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + PersistentVolumeSource: corev1.PersistentVolumeSource{ + CSI: &corev1.CSIPersistentVolumeSource{ + Driver: "ebs.csi.aws.com", + VolumeHandle: "vol-abc123", + }, + }, + }, +} + +resource, err := pv.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +PersistentVolumes are cluster-scoped. The builder validates that `Name` is set and that `Namespace` is empty. Setting a +namespace on the PV object causes `Build()` to return an error. + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example using the `SetStorageClassName` convenience method: + +```go +func RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation { + return pv.Mutation{ + Name: "retain-policy", + Feature: feature.NewVersionGate(version, nil).When(retainEnabled), + Mutate: func(m *pv.Mutator) error { + m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | -------------- | ------------------------------------------------------------------ | +| 1 | Metadata edits | Labels and annotations on the `PersistentVolume` | +| 2 | Spec edits | PV spec fields: storage class, reclaim policy, mount options, etc. | + +Within each category, edits run in registration order. Later features observe the PersistentVolume as modified by all +earlier ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### PVSpecEditor + +The primary API for modifying PersistentVolume spec fields. Use `m.EditPVSpec` for full control: + +```go +m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.SetCapacity(resource.MustParse("200Gi")) + e.SetAccessModes([]corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}) + e.SetPersistentVolumeReclaimPolicy(corev1.PersistentVolumeReclaimRetain) + return nil +}) +``` + +#### Available methods + +| Method | What it sets | +| -------------------------------------- | -------------------------------------- | +| `SetCapacity(resource.Quantity)` | `.spec.capacity[storage]` | +| `SetAccessModes([]AccessMode)` | `.spec.accessModes` | +| `SetPersistentVolumeReclaimPolicy` | `.spec.persistentVolumeReclaimPolicy` | +| `SetStorageClassName(string)` | `.spec.storageClassName` | +| `SetMountOptions([]string)` | `.spec.mountOptions` | +| `SetVolumeMode(PersistentVolumeMode)` | `.spec.volumeMode` | +| `SetNodeAffinity(*VolumeNodeAffinity)` | `.spec.nodeAffinity` | +| `Raw()` | Returns `*corev1.PersistentVolumeSpec` | + +#### Raw escape hatch + +`Raw()` returns the underlying `*corev1.PersistentVolumeSpec` for free-form editing: + +```go +m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.Raw().PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimDelete + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("storage-tier", "premium") + e.EnsureAnnotation("provisioned-by", "my-operator") + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes convenience wrappers for the most common PV spec operations: + +| Method | Equivalent to | +| --------------------------- | ------------------------------------------------------ | +| `SetStorageClassName(name)` | `EditPVSpec` → `e.SetStorageClassName(name)` | +| `SetReclaimPolicy(policy)` | `EditPVSpec` → `e.SetPersistentVolumeReclaimPolicy(p)` | +| `SetMountOptions(opts)` | `EditPVSpec` → `e.SetMountOptions(opts)` | + +Use these for simple, single-operation mutations. Use `EditPVSpec` when you need multiple operations or raw access in a +single edit block. + +## Operational Status + +The PV primitive implements `concepts.Operational`. The default handler maps PV phase to operational status: + +| PV Phase | Status | Meaning | +| --------- | ------------------ | -------------------------------------- | +| Available | `Operational` | PV is ready for binding | +| Bound | `Operational` | PV is bound to a PersistentVolumeClaim | +| Pending | `OperationPending` | PV is waiting to become available | +| Released | `OperationFailing` | PV was released, not yet reclaimed | +| Failed | `OperationFailing` | PV reclamation has failed | + +Override with `WithCustomOperationalStatus` when your PV requires different readiness logic. + +## Grace Status + +The default grace status handler maps the PV phase to a grace status after the grace period expires: + +| PV Phase | Status | Meaning | +| --------- | ---------- | -------------------------------------- | +| Available | `Healthy` | PV is ready for binding | +| Bound | `Healthy` | PV is bound to a PersistentVolumeClaim | +| Pending | `Degraded` | PV is waiting to become available | +| Released | `Down` | PV was released, not yet reclaimed | +| Failed | `Down` | PV reclamation has failed | + +Override with `WithCustomGraceStatus`: + +```go +pv.NewBuilder(base). + WithCustomGraceStatus(func(p *corev1.PersistentVolume) (concepts.GraceStatusWithReason, error) { + status, err := pv.DefaultGraceStatusHandler(p) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Full Example + +```go +func StorageClassMutation(version string) pv.Mutation { + return pv.Mutation{ + Name: "storage-class", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *pv.Mutator) error { + m.SetStorageClassName("fast-ssd") + m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain) + return nil + }, + } +} + +func TierLabelMutation(version, tier string) pv.Mutation { + return pv.Mutation{ + Name: "tier-label", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *pv.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("storage-tier", tier) + return nil + }) + return nil + }, + } +} + +resource, err := pv.NewBuilder(base). + WithMutation(StorageClassMutation(owner.Spec.Version)). + WithMutation(TierLabelMutation(owner.Spec.Version, "premium")). + Build() +``` + +## Guidance + +**PersistentVolumes are cluster-scoped.** Do not set a namespace on the PV object. The builder rejects namespaced PVs +with a clear error. + +**Understand the garbage collection constraint.** The component reconciliation pipeline attempts to set a controller +reference on created/updated resources. Because `PersistentVolume` is cluster-scoped, its controller owner must also be +cluster-scoped. When the owner is namespace-scoped, the framework detects the mismatch and skips setting +`ownerReferences` instead of letting the API server reject the request. Such PVs will not be garbage collected +automatically when the owning component is deleted. Either model the PV under a dedicated cluster-scoped component to +allow a valid controller reference, or accept that PVs managed from a namespace-scoped component require explicit +lifecycle handling. + +**Use string status values in conditions.** The operational status values that appear in conditions are the runtime +strings `"Operational"`, `"OperationPending"`, and `"OperationFailing"`, not the Go constant identifiers. + +**Register mutations in dependency order.** If mutation B relies on a field set by mutation A, register A first. diff --git a/plugin/skills/using-primitives/references/primitives/pvc.md b/plugin/skills/using-primitives/references/primitives/pvc.md new file mode 100644 index 00000000..777ce034 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/pvc.md @@ -0,0 +1,256 @@ +# PersistentVolumeClaim Primitive + +The `pvc` primitive wraps a Kubernetes `PersistentVolumeClaim` and integrates with the component lifecycle as an +Integration, Graceful, and Suspendable resource, providing a structured mutation API for managing storage requests and +object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ----------------------------------------------------------------------------------------- | +| **Operational** | Maps PVC phase to `Operational` (Bound), `OperationPending`, or `OperationFailing` (Lost) | +| **Graceful** | Bound is `Healthy`; Lost is `Down`; any other phase is `Degraded` | +| **Suspendable** | Immediately suspended (no runtime state to wind down); data is preserved by default | +| **DataExtractable** | Reads bound volume name, capacity, or other status fields after each sync cycle | +| **Mutation pipeline** | Typed editors for PVC spec and object metadata, with a `Raw()` escape hatch | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. + +## Building a PVC Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pvc" + +base := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-data", + Namespace: owner.Namespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, +} + +resource, err := pvc.NewBuilder(base). + WithMutation(MyStorageMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example using the `SetStorageRequest` convenience method: + +```go +func MyStorageMutation(version string) pvc.Mutation { + return pvc.Mutation{ + Name: "storage-expansion", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *pvc.Mutator) error { + m.SetStorageRequest(resource.MustParse("20Gi")) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | -------------- | ----------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `PersistentVolumeClaim` | +| 2 | Spec edits | PVC spec: storage requests, access modes, etc. | + +Within each category, edits run in registration order. Later features observe the PVC as modified by all earlier ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### PVCSpecEditor + +The primary API for modifying PVC spec fields. Use `m.EditPVCSpec` for full control: + +```go +m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + e.SetStorageRequest(resource.MustParse("20Gi")) + return nil +}) +``` + +Available methods: + +| Method | What it does | +| --------------------- | ------------------------------------------------------- | +| `SetStorageRequest` | Sets `spec.resources.requests[storage]` | +| `SetAccessModes` | Sets `spec.accessModes` (immutable after creation) | +| `SetStorageClassName` | Sets `spec.storageClassName` (immutable after creation) | +| `SetVolumeMode` | Sets `spec.volumeMode` (immutable after creation) | +| `SetVolumeName` | Sets `spec.volumeName` (immutable after creation) | +| `Raw` | Returns `*corev1.PersistentVolumeClaimSpec` | + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*corev1.PersistentVolumeClaimSpec` for free-form editing: + +```go +m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + raw := e.Raw() + raw.Selector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"type": "fast"}, + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("storage/class-hint", "fast-ssd") + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes a convenience wrapper for the most common PVC operation: + +| Method | Equivalent to | +| ----------------------------- | ----------------------------------------------- | +| `SetStorageRequest(quantity)` | `EditPVCSpec` → `e.SetStorageRequest(quantity)` | + +Use this for simple, single-operation mutations. Use `EditPVCSpec` when you need multiple operations or raw access in a +single edit block. + +## Operational Status + +The PVC primitive implements `concepts.Operational`. The default handler maps PVC phase to operational status: + +| PVC Phase | Status | Reason | +| --------- | ------------------ | ------------------------------- | +| `Bound` | `Operational` | PVC is bound to volume `` | +| `Pending` | `OperationPending` | Waiting for PVC to be bound | +| `Lost` | `OperationFailing` | PVC has lost its bound volume | + +Override with `WithCustomOperationalStatus` for additional checks. + +## Grace Status + +The default grace status handler maps the PVC phase to a grace status after the grace period expires: + +| PVC Phase | Status | Meaning | +| --------- | ---------- | ----------------------------- | +| `Bound` | `Healthy` | PVC is bound to a volume | +| `Lost` | `Down` | PVC has lost its bound volume | +| Other | `Degraded` | PVC is not yet bound | + +Override with `WithCustomGraceStatus`: + +```go +pvc.NewBuilder(base). + WithCustomGraceStatus(func(p *corev1.PersistentVolumeClaim) (concepts.GraceStatusWithReason, error) { + status, err := pvc.DefaultGraceStatusHandler(p) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Suspension + +PVCs have no runtime state to wind down: + +- `DefaultSuspendMutationHandler` is a no-op. +- `DefaultSuspensionStatusHandler` always reports `Suspended`. +- `DefaultDeleteOnSuspendHandler` returns `false` to preserve data. + +Override these handlers if you need custom suspension behavior, such as adding annotations when suspended or deleting +PVCs that use ephemeral storage: + +```go +resource, err := pvc.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *corev1.PersistentVolumeClaim) bool { + return true // delete on suspend + }). + Build() +``` + +## Full Example + +```go +func StorageRequestMutation(version string) pvc.Mutation { + return pvc.Mutation{ + Name: "storage-request", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *pvc.Mutator) error { + m.SetStorageRequest(resource.MustParse("10Gi")) + return nil + }, + } +} + +var v2Constraint = mustSemverConstraint(">= 2.0.0") + +func ExpandedStorageMutation(version string) pvc.Mutation { + return pvc.Mutation{ + Name: "expanded-storage", + Feature: feature.NewVersionGate( + version, + []feature.VersionConstraint{v2Constraint}, + ), + Mutate: func(m *pvc.Mutator) error { + m.SetStorageRequest(resource.MustParse("50Gi")) + return nil + }, + } +} + +var boundVolumeName string + +resource, err := pvc.NewBuilder(base). + WithMutation(StorageRequestMutation(owner.Spec.Version)). + WithMutation(ExpandedStorageMutation(owner.Spec.Version)). + WithDataExtractor(func(p corev1.PersistentVolumeClaim) error { + boundVolumeName = p.Spec.VolumeName + return nil + }). + Build() +``` + +On versions 2.0.0 and above, `ExpandedStorageMutation` fires and sets the storage request to 50Gi. On earlier versions, +only the base 10Gi request is applied. After each reconcile cycle, the data extractor captures the bound volume name. + +## Guidance + +**Register storage expansion mutations carefully.** Kubernetes allows expanding PVC storage but not shrinking it. Ensure +your mutations respect this constraint. The `SetStorageRequest` method does not enforce this; the API server rejects +invalid requests. + +**Prefer `WithCustomSuspendDeletionDecision` over deleting PVCs manually.** If you need PVCs to be cleaned up during +suspension, register a deletion decision handler rather than deleting them in a mutation. + +**Use `WithDataExtractor` to read bound volume information.** The bound volume name and actual allocated capacity are +server-assigned. Read them with a data extractor after reconciliation rather than caching them in mutation logic. + +**Use string status values in conditions.** The operational status values that appear in conditions are the runtime +strings `"Operational"`, `"OperationPending"`, and `"OperationFailing"`, not the Go constant identifiers. diff --git a/plugin/skills/using-primitives/references/primitives/replicaset.md b/plugin/skills/using-primitives/references/primitives/replicaset.md new file mode 100644 index 00000000..2d7cb9b2 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/replicaset.md @@ -0,0 +1,228 @@ +# ReplicaSet Primitive + +The `replicaset` primitive wraps a Kubernetes `ReplicaSet` and provides health tracking, suspension, and a typed +mutation API for managing replicas, pod spec, and containers as part of the component lifecycle. + +ReplicaSets are rarely managed directly by operators. Deployments own and manage ReplicaSets automatically. This +primitive is intended for operators that explicitly own ReplicaSet objects, such as custom rollout controllers that +manage sets of pods without Deployment's rollout semantics. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | ------------------------------------------------------- | +| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a ReplicaSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" + +base := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker", + Namespace: owner.Namespace, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "worker"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "worker"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "worker"}, + }, + }, + }, + }, +} + +resource, err := replicaset.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `replicaset.Mutation` that receives a `*replicaset.Mutator` and records edits through typed +editors. + +```go +func WorkerConfigMutation(version string) replicaset.Mutation { + return replicaset.Mutation{ + Name: "worker-config", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *replicaset.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "WORKER_THREADS", Value: "4"}) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `ReplicaSet` object | +| 2 | ReplicaSetSpec edits | Replicas, min ready seconds | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### ReplicaSetSpecEditor + +Controls replicaset-level settings via `m.EditReplicaSetSpec`. + +Available methods: `SetReplicas`, `SetMinReadySeconds`, `Raw`. + +```go +m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetReplicas(3) + e.SetMinReadySeconds(10) + return nil +}) +``` + +!!! note "`spec.selector` is immutable" + + `spec.selector` cannot be changed after the ReplicaSet is created. Set it in the desired object passed to + `NewBuilder`; it is not exposed by `ReplicaSetSpecEditor`. + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("worker-sa") + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` for the `ReplicaSet` itself or `m.EditPodTemplateMetadata` +for the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureReplicas(n)` | `EditReplicaSetSpec` → `SetReplicas(n)` | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Workload-Kind-Agnostic Mutations + +The `replicaset.Mutator` does not implement `primitives.WorkloadMutator` and therefore does not have a `LiftMutation` +adapter. Workload-kind-agnostic mutations target the Deployment, StatefulSet, and DaemonSet mutators. If you need to +share container or env-var mutations across those kinds and a ReplicaSet, write the shared logic as a plain function +that accepts `*replicaset.Mutator` and call it directly from a `replicaset.Mutation`. + +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern. + +## Suspension + +When the component is suspended, the ReplicaSet is scaled to zero replicas. The resource is not deleted. + +- `DefaultSuspendMutationHandler` calls `EnsureReplicas(0)`. +- `DefaultSuspensionStatusHandler` reports `Suspending` while `Status.Replicas > 0`, then `Suspended`. +- `DefaultDeleteOnSuspendHandler` returns `false`. + +Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision` +on the builder. + +## Full Example + +```go +func WorkerMutation(version string, replicas int32) replicaset.Mutation { + return replicaset.Mutation{ + Name: "worker-sizing", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *replicaset.Mutator) error { + m.EnsureReplicas(replicas) + + m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "WORKER_THREADS", Value: "4"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi")) + return nil + }) + + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("worker-sa") + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**Prefer Deployments over direct ReplicaSet management.** Deployments add rolling-update semantics and revision history. +Use this primitive only when you are building a custom rollout controller or you have a specific reason to own +ReplicaSet objects directly. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so selectors in the +same mutation resolve correctly and reconciliation remains idempotent. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/plugin/skills/using-primitives/references/primitives/role.md b/plugin/skills/using-primitives/references/primitives/role.md new file mode 100644 index 00000000..29664c65 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/role.md @@ -0,0 +1,228 @@ +# Role Primitive + +The `role` primitive wraps a Kubernetes `Role` and manages RBAC policy rules and object metadata within the component +lifecycle. + +## Capabilities + +| Capability | Interfaces / detail | +| -------------------- | -------------------------------------------------------------------------------------- | +| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension | +| **Mutation** | `PolicyRulesEditor` for `.rules`; `ObjectMetaEditor` for labels and annotations | +| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) | +| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. + +## Building a Role Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/role" + +base := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-role", + Namespace: owner.Namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, +} + +resource, err := role.NewBuilder(base). + WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableSecretAccess)). + Build() +``` + +`Build()` returns an error if `Name` or `Namespace` is empty. + +Identity format: `rbac.authorization.k8s.io/v1/Role//`. + +## Mutations + +Each mutation is a named `role.Mutation` that receives a `*Mutator` and records edit intent through typed editors. See +[The Mutation System](../primitives.md#the-mutation-system) for the full model. + +```go +func SecretAccessMutation(version string, enabled bool) role.Mutation { + return role.Mutation{ + Name: "secret-access", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }) + return nil + }, + } +} +``` + +For boolean conditions, chain `.When()` on the gate. See +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in this fixed category order regardless of the call order: + +| Step | Category | What it affects | +| ---- | -------------- | -------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the Role | +| 2 | Rules edits | `.rules`: `SetRules`, `AddRule`, `Raw` | + +Within each category, edits apply in registration order. Later features observe the object as modified by all earlier +ones. + +## Relevant Editors + +### PolicyRulesEditor + +The primary API for modifying `.rules`. Use `m.EditRules` for full control. See +[Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +#### SetRules + +`SetRules` replaces the entire rules slice atomically. Use this when a mutation should define the complete set of rules, +discarding any previously accumulated entries. + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}}, + }) + return nil +}) +``` + +#### AddRule + +`AddRule` appends a single rule to the existing rules slice. Use this when a feature contributes additional permissions +without needing to know about rules from other features. + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "watch"}, + }) + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.PolicyRule` for direct manipulation when none of the structured +methods are sufficient: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + raw := e.Raw() + filtered := (*raw)[:0] + for _, r := range *raw { + if !containsVerb(r.Verbs, "create") { + filtered = append(filtered, r) + } + } + *raw = filtered + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("managed-by", "my-operator") + return nil +}) +``` + +## Data Extraction + +`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled Role. Use it to +surface the applied rules or metadata to other resources: + +```go +resource, err := role.NewBuilder(base). + WithDataExtractor(func(r rbacv1.Role) error { + sharedState.RoleName = r.Name + return nil + }). + Build() +``` + +## Full Example + +```go +func BaseRuleMutation(version string) role.Mutation { + return role.Mutation{ + Name: "base-rules", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}}, + }) + return nil + }) + return nil + }, + } +} + +func SecretAccessMutation(version string, enabled bool) role.Mutation { + return role.Mutation{ + Name: "secret-access", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }) + return nil + }, + } +} + +resource, err := role.NewBuilder(base). + WithMutation(BaseRuleMutation(owner.Spec.Version)). + WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableSecretAccess)). + Build() +``` + +When `EnableSecretAccess` is true, the final Role contains both the base pod rules and the secrets rule. When false, +only the base rules are applied. Neither mutation needs to know about the other. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use +`feature.NewVersionGate(version, constraints)` when version gating is needed, and chain `.When(bool)` for boolean +conditions. + +**Use `AddRule` for composable permissions.** When multiple features contribute rules to the same Role, `AddRule` lets +each feature add its permissions independently. `SetRules` in multiple features means the last registration wins; only +use that when full replacement is the intended semantics. + +**PolicyRule has no unique key.** There is no upsert or remove-by-key operation on rules. Use `SetRules` to replace +atomically, `AddRule` to accumulate, or `Raw()` for arbitrary manipulation including filtering. + +**Register mutations in dependency order.** If mutation B relies on rules set by mutation A, register A first. diff --git a/plugin/skills/using-primitives/references/primitives/rolebinding.md b/plugin/skills/using-primitives/references/primitives/rolebinding.md new file mode 100644 index 00000000..0017bfa3 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/rolebinding.md @@ -0,0 +1,223 @@ +# RoleBinding Primitive + +The `rolebinding` primitive wraps a Kubernetes `RoleBinding` and manages the subjects list and object metadata within +the component lifecycle. + +## Capabilities + +| Capability | Interfaces / detail | +| --------------------- | -------------------------------------------------------------------------------------- | +| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension | +| **Mutation** | `BindingSubjectsEditor` for `.subjects`; `ObjectMetaEditor` for labels and annotations | +| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation | +| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) | +| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. + +## Building a RoleBinding Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/rolebinding" + +base := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-rolebinding", + Namespace: owner.Namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "app-role", + }, + Subjects: []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "app-sa", Namespace: owner.Namespace}, + }, +} + +resource, err := rolebinding.NewBuilder(base). + WithMutation(MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)). + Build() +``` + +`Build()` returns an error if `Name` or `Namespace` is empty, or if `roleRef.APIGroup`, `roleRef.Kind`, or +`roleRef.Name` is empty. + +`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not +modifiable via the mutation API. To change a `roleRef`, delete and recreate the RoleBinding. + +Identity format: `rbac.authorization.k8s.io/v1/RoleBinding//`. + +## Mutations + +Each mutation is a named `rolebinding.Mutation` that receives a `*Mutator` and records edit intent through typed +editors. See [The Mutation System](../primitives.md#the-mutation-system) for the full model. + +```go +func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "monitoring-subject", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("monitoring-agent", "monitoring") + return nil + }) + return nil + }, + } +} +``` + +For boolean conditions, chain `.When()` on the gate. See +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in this fixed category order regardless of the call order: + +| Step | Category | What it affects | +| ---- | -------------- | ----------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the RoleBinding | +| 2 | Subject edits | `.subjects` entries via `BindingSubjectsEditor` | + +Within each category, edits apply in registration order. Later features observe the object as modified by all earlier +ones. + +## Relevant Editors + +### BindingSubjectsEditor + +The primary API for modifying the subjects list. Use `m.EditSubjects` for full control. See +[Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "my-sa", + Namespace: "default", + }) + e.RemoveSubject("ServiceAccount", "old-sa", "default") + return nil +}) +``` + +#### EnsureSubject + +`EnsureSubject` upserts a subject by the combination of `Kind`, `Name`, and `Namespace`. If a matching subject already +exists it is replaced; otherwise the new subject is appended. + +#### EnsureServiceAccount + +Convenience wrapper that ensures a `ServiceAccount` subject with the given name and namespace exists. + +```go +e.EnsureServiceAccount("app-sa", "production") +``` + +#### RemoveSubject and RemoveServiceAccount + +`RemoveSubject` removes a subject identified by kind, name, and namespace. `RemoveServiceAccount` is a convenience +wrapper for removing `ServiceAccount` subjects: + +```go +e.RemoveSubject("User", "old-user", "") +e.RemoveServiceAccount("deprecated-sa", "default") +``` + +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` for free-form editing: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + raw := e.Raw() + *raw = append(*raw, rbacv1.Subject{ + Kind: "Group", + Name: "developers", + }) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator") + e.EnsureAnnotation("operator.example.io/version", version) + return nil +}) +``` + +## Data Extraction + +`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled RoleBinding. Use +it to surface binding metadata to other resources: + +```go +resource, err := rolebinding.NewBuilder(base). + WithDataExtractor(func(rb rbacv1.RoleBinding) error { + sharedState.RoleBindingName = rb.Name + return nil + }). + Build() +``` + +## Full Example + +```go +func BaseSubjectMutation(version string, saName, saNamespace string) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "base-subject", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount(saName, saNamespace) + return nil + }) + return nil + }, + } +} + +func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "monitoring-subject", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("monitoring-agent", "monitoring") + return nil + }) + return nil + }, + } +} + +resource, err := rolebinding.NewBuilder(base). + WithMutation(BaseSubjectMutation(owner.Spec.Version, "app-sa", owner.Namespace)). + WithMutation(MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)). + Build() +``` + +When `EnableMonitoring` is true, the binding's subjects list contains both the base service account and the monitoring +agent. When false, only the base subject is present. Neither mutation needs to know about the other. + +## Guidance + +**Set `roleRef` on the base object, not via mutations.** Kubernetes makes `roleRef` immutable after creation. To change +a `roleRef`, delete and recreate the RoleBinding. + +**Use `EnsureSubject` for idempotent subject management.** `EnsureSubject` upserts by Kind+Name+Namespace, making it +safe to call on every reconciliation without creating duplicates. + +**Use `EnsureServiceAccount` as a shortcut for the most common subject type.** It sets `Kind`, `Name`, and `Namespace` +in one call and is equivalent to `EnsureSubject` with a `ServiceAccount` kind. + +**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first. diff --git a/plugin/skills/using-primitives/references/primitives/secret.md b/plugin/skills/using-primitives/references/primitives/secret.md new file mode 100644 index 00000000..7c7aee84 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/secret.md @@ -0,0 +1,311 @@ +# Secret Primitive + +The `secret` primitive wraps a Kubernetes `Secret` and integrates with the component lifecycle as a Static resource, +providing a structured mutation API for managing `.data` and `.stringData` entries and object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ---------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a `Raw()` escape hatch | +| **DataExtractable** | Reads values back from the reconciled Secret after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. + +## Building a Secret Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + +base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-credentials", + Namespace: owner.Namespace, + }, + Data: map[string][]byte{ + "password": []byte("default-password"), + }, +} + +resource, err := secret.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example using the `SetData` convenience method: + +```go +func MyFeatureMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "my-feature", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *secret.Mutator) error { + m.SetData("feature-flag", []byte("enabled")) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | -------------- | --------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Secret` | +| 2 | Data edits | `.data` and `.stringData` entries: Set, Remove, Raw | + +Within each category, edits run in registration order. Later features observe the Secret as modified by all earlier +ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### SecretDataEditor + +The primary API for modifying `.data` and `.stringData` entries. Use `m.EditData` for full control: + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("password", []byte("new-password")) + e.Remove("stale-key") + e.SetString("config-value", "plaintext") + return nil +}) +``` + +#### Set and Remove (.data) + +`Set` adds or overwrites a `.data` key with a byte slice value. `Remove` deletes a `.data` key; it is a no-op if the key +is absent. + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("api-key", []byte("secret-value")) + e.Remove("deprecated-key") + return nil +}) +``` + +#### SetString and RemoveString (.stringData) + +`SetString` adds or overwrites a `.stringData` key with a plaintext value. The API server merges `.stringData` into +`.data` on write. `RemoveString` deletes a `.stringData` key; it is a no-op if the key is absent. + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.SetString("username", "admin") + e.RemoveString("old-username") + return nil +}) +``` + +#### Raw Escape Hatches + +`Raw()` returns the underlying `map[string][]byte` for `.data`. `RawStringData()` returns the underlying +`map[string]string` for `.stringData`. Both give direct access for free-form editing: + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + raw := e.Raw() + for k, v := range externalDefaults { + if _, exists := raw[k]; !exists { + raw[k] = v + } + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("checksum/secret", secretHash) + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes convenience wrappers for the most common `.data` and `.stringData` operations: + +| Method | Equivalent to | +| --------------------------- | -------------------------------------- | +| `SetData(key, value)` | `EditData` → `e.Set(key, value)` | +| `RemoveData(key)` | `EditData` → `e.Remove(key)` | +| `SetStringData(key, value)` | `EditData` → `e.SetString(key, value)` | +| `RemoveStringData(key)` | `EditData` → `e.RemoveString(key)` | + +Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a +single edit block. + +## Data Hash + +Two utilities compute a stable SHA-256 hash of a Secret's effective data content (`.data` plus `.stringData` merged +using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template with this hash so that a +secret change triggers a rolling restart. + +### DataHash + +`DataHash` hashes a Secret value you already have, for example one read from the cluster: + +```go +hash, err := secret.DataHash(s) +``` + +The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically. +`.stringData` entries are merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing, +matching Kubernetes API-server write semantics. This ensures the hash is consistent whether called on a desired object +or a cluster-read object. + +### Resource.DesiredHash + +`DesiredHash` computes the hash of what the operator _will write_ (the base object with all registered mutations +applied) without performing a cluster read and without a second reconcile cycle: + +```go +secretResource, err := secret.NewBuilder(base). + WithMutation(BaseSecretMutation(owner.Spec.Version)). + WithMutation(TLSMutation(owner.Spec.EnableTLS)). + Build() + +hash, err := secretResource.DesiredHash() +``` + +The hash covers only operator-controlled fields. + +### Annotating a Deployment pod template (single-pass pattern) + +Build the Secret resource first, compute the hash, then pass it into the Deployment resource factory. Both resources are +registered with the same component, so the Secret is reconciled first and the Deployment sees the correct hash on every +cycle. + +`DesiredHash` is defined on `*secret.Resource`, not on the `component.Resource` interface, so keep the concrete type +when you need to call it: + +```go +secretResource, err := secret.NewBuilder(base). + WithMutation(features.BaseSecretMutation(owner.Spec.Version)). + WithMutation(features.TLSMutation(owner.Spec.Version, owner.Spec.EnableTLS)). + Build() +if err != nil { + return err +} + +hash, err := secretResource.DesiredHash() +if err != nil { + return err +} + +deployResource, err := resources.NewDeploymentResource(owner, hash) +if err != nil { + return err +} + +comp, err := component.NewComponentBuilder(). + WithResource(secretResource). // reconciled first + WithResource(deployResource). + Build() +``` + +```go +// In NewDeploymentResource, use the hash in a mutation: +func ChecksumAnnotationMutation(version, secretHash string) deployment.Mutation { + return deployment.Mutation{ + Name: "secret-checksum", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("checksum/secret", secretHash) + return nil + }) + return nil + }, + } +} +``` + +When the Secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same +reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. + +## Full Example + +```go +func BaseSecretMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "base-secret", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("auth-mode", "token") + return nil + }, + } +} + +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyTokenMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "legacy-token", + Feature: feature.NewVersionGate( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("auth-mode", "legacy-token") + return nil + }, + } +} + +func TLSSecretMutation(version string, tlsEnabled bool) secret.Mutation { + return secret.Mutation{ + Name: "tls-secret", + Feature: feature.NewVersionGate(version, nil).When(tlsEnabled), + Mutate: func(m *secret.Mutator) error { + m.SetData("tls.crt", certBytes) + m.SetData("tls.key", keyBytes) + return nil + }, + } +} + +resource, err := secret.NewBuilder(base). + WithMutation(BaseSecretMutation(owner.Spec.Version)). + WithMutation(LegacyTokenMutation(owner.Spec.Version)). + WithMutation(TLSSecretMutation(owner.Spec.Version, owner.Spec.EnableTLS)). + Build() +``` + +On versions below 2.0.0 the `auth-mode` key is overwritten to `legacy-token` by the version-gated mutation. On 2.0.0 and +above only the base value is written. When TLS is enabled, the certificate bytes are added regardless of version. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions. + +**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. + +**Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids +manual encoding in mutation code. + +**Use `DesiredHash` for rolling restarts triggered by secret rotation.** Build the Secret resource, call +`DesiredHash()`, and stamp the result as a pod-template annotation on the Deployment in the same reconcile pass. diff --git a/plugin/skills/using-primitives/references/primitives/service.md b/plugin/skills/using-primitives/references/primitives/service.md new file mode 100644 index 00000000..609efd26 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/service.md @@ -0,0 +1,309 @@ +# Service Primitive + +The `service` primitive wraps a Kubernetes `Service` and integrates with the component lifecycle as an Integration, +Graceful, and Suspendable resource. + +## Capabilities + +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------- | +| **Operational** | Monitors LoadBalancer ingress assignment; reports `Operational` or `OperationPending` | +| **Graceful** | LoadBalancer with no ingress reports `Degraded`; non-LoadBalancer or assigned ingress is `Healthy` | +| **Suspendable** | No-op by default; Service is left in place. Customizable via handlers | +| **DataExtractable** | Reads assigned ClusterIP or LoadBalancer ingress after each sync cycle | +| **Mutation pipeline** | Typed editors for metadata and Service spec, with a `Raw()` escape hatch for free-form access | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface +reports. + +## Building a Service Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/service" + +base := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-svc", + Namespace: owner.Namespace, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": owner.Name}, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080)}, + }, + }, +} + +resource, err := service.NewBuilder(base). + WithMutation(BaseServiceMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are +explained in [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +A kind-specific example, gating a NodePort mutation on a boolean condition: + +```go +func NodePortMutation(version string, enabled bool) service.Mutation { + return service.Mutation{ + Name: "nodeport", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.SetType(corev1.ServiceTypeNodePort) + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in a fixed category order regardless of recording order: + +| Step | Category | What it affects | +| ---- | -------------- | ---------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Service` | +| 2 | ServiceSpec | Ports, selectors, type, traffic policies | + +Within each category, edits run in registration order. Later features observe the Service as modified by all earlier +ones. + +## Relevant Editors + +See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model. + +### ServiceSpecEditor + +Controls Service-level settings via `m.EditServiceSpec`. + +Available methods: `SetType`, `EnsurePort`, `RemovePort`, `SetSelector`, `EnsureSelector`, `RemoveSelector`, +`SetSessionAffinity`, `SetSessionAffinityConfig`, `SetPublishNotReadyAddresses`, `SetExternalTrafficPolicy`, +`SetInternalTrafficPolicy`, `SetLoadBalancerSourceRanges`, `SetExternalName`, `Raw`. + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.SetType(corev1.ServiceTypeLoadBalancer) + e.EnsurePort(corev1.ServicePort{ + Name: "https", + Port: 443, + TargetPort: intstr.FromInt32(8443), + }) + e.SetExternalTrafficPolicy(corev1.ServiceExternalTrafficPolicyLocal) + return nil +}) +``` + +#### Port Management + +`EnsurePort` upserts a port: if a port with the same `Name` exists it is replaced; when `Name` is empty the match uses +the combination of `Port` and the effective `Protocol` (treating an empty protocol as TCP). TCP and UDP ports with the +same port number are distinct unless protocols match explicitly. If no existing port matches, the new port is appended. +`RemovePort` removes a port by name. + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + e.RemovePort("legacy") + return nil +}) +``` + +#### Selector Management + +`SetSelector` replaces the entire selector map. `EnsureSelector` adds or updates a single key-value pair. +`RemoveSelector` removes a single key. + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsureSelector("app", "web") + e.EnsureSelector("tier", "frontend") + return nil +}) +``` + +Use `Raw()` for fields not covered by the typed API: + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.Raw().HealthCheckNodePort = 30000 + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("service.beta.kubernetes.io/aws-load-balancer-type", "nlb") + return nil +}) +``` + +## Data Extraction + +Use `WithDataExtractor` to read values from the reconciled Service after each sync cycle, such as the assigned ClusterIP +or LoadBalancer ingress: + +```go +var assignedIP string + +resource, err := service.NewBuilder(base). + WithDataExtractor(func(svc corev1.Service) error { + assignedIP = svc.Spec.ClusterIP + return nil + }). + Build() +``` + +## Operational Status + +The Service primitive implements `concepts.Operational`. The default handler reports: + +| Service Type | Condition | Status | +| -------------- | --------------------------------------------------------------------------- | ------------------ | +| `LoadBalancer` | `Status.LoadBalancer.Ingress` has no entry with an IP or hostname | `OperationPending` | +| `LoadBalancer` | `Status.LoadBalancer.Ingress` has at least one entry with an IP or hostname | `Operational` | +| `ClusterIP` | Always | `Operational` | +| `NodePort` | Always | `Operational` | +| `ExternalName` | Always | `Operational` | +| Headless | Always | `Operational` | + +Override with `WithCustomOperationalStatus`: + +```go +resource, err := service.NewBuilder(base). + WithCustomOperationalStatus(func(op concepts.ConvergingOperation, svc *corev1.Service) (concepts.OperationalStatusWithReason, error) { + return service.DefaultOperationalStatusHandler(op, svc) + }). + Build() +``` + +## Grace Status + +The default grace status handler assesses health after the grace period expires: + +| Service Type | Condition | Status | +| -------------- | ----------------------------------------- | ---------- | +| `LoadBalancer` | `Status.LoadBalancer.Ingress` has entries | `Healthy` | +| `LoadBalancer` | `Status.LoadBalancer.Ingress` is empty | `Degraded` | +| `ClusterIP` | Always | `Healthy` | +| `NodePort` | Always | `Healthy` | +| `ExternalName` | Always | `Healthy` | +| Headless | Always | `Healthy` | + +Override with `WithCustomGraceStatus`: + +```go +service.NewBuilder(base). + WithCustomGraceStatus(func(svc *corev1.Service) (concepts.GraceStatusWithReason, error) { + status, err := service.DefaultGraceStatusHandler(svc) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Suspension + +By default, Services are unaffected by suspension. They remain in the cluster when the parent component is suspended. +`DefaultDeleteOnSuspendHandler` returns `false`, `DefaultSuspendMutationHandler` is a no-op, and +`DefaultSuspensionStatusHandler` reports `Suspended` immediately. + +This is appropriate for most use cases because Services are stateless routing objects that are safe to leave in place. + +Override with `WithCustomSuspendDeletionDecision` to delete the Service on suspend: + +```go +resource, err := service.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *corev1.Service) bool { + return true + }). + Build() +``` + +Combine `WithCustomSuspendMutation` and `WithCustomSuspendStatus` for more advanced suspension behavior, such as +modifying the Service before deletion or tracking external readiness before reporting suspended. + +## Full Example + +```go +func BaseServiceMutation(version string) service.Mutation { + return service.Mutation{ + Name: "base-service", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{ + Name: "http", + Port: 80, + TargetPort: intstr.FromInt32(8080), + }) + return nil + }) + return nil + }, + } +} + +func MetricsPortMutation(version string, enabled bool) service.Mutation { + return service.Mutation{ + Name: "metrics-port", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{ + Name: "metrics", + Port: 9090, + TargetPort: intstr.FromInt32(9090), + }) + return nil + }) + return nil + }, + } +} + +var assignedIP string + +resource, err := service.NewBuilder(base). + WithMutation(BaseServiceMutation(owner.Spec.Version)). + WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)). + WithDataExtractor(func(svc corev1.Service) error { + assignedIP = svc.Spec.ClusterIP + return nil + }). + Build() +``` + +When `EnableMetrics` is true, the Service exposes both the HTTP and metrics ports. When false, only HTTP is configured. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Chain `.When(bool)` for +boolean conditions and pass version constraints to `NewVersionGate` for version-gated behavior. + +**Register mutations in dependency order.** If mutation B depends on a port added by mutation A, register A first. + +**Use `EnsurePort` for idempotent port management.** Ports are tracked by name (or port number when unnamed), so +repeated calls with the same name produce the same result. + +**Leave Services in place during suspension.** The no-op default is correct for most Services. Only override +`WithCustomSuspendDeletionDecision` when your use case requires explicitly removing the Service during suspension. + +**Use `WithDataExtractor` for assigned addresses.** ClusterIP and LoadBalancer ingress are server-assigned. Read them +with a data extractor after reconciliation rather than caching them in mutation logic. diff --git a/plugin/skills/using-primitives/references/primitives/serviceaccount.md b/plugin/skills/using-primitives/references/primitives/serviceaccount.md new file mode 100644 index 00000000..7d936ee3 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/serviceaccount.md @@ -0,0 +1,185 @@ +# ServiceAccount Primitive + +The `serviceaccount` primitive wraps a Kubernetes `ServiceAccount` and manages image pull secrets, the automount token +flag, and object metadata within the component lifecycle. + +## Capabilities + +| Capability | Interfaces / detail | +| -------------------- | --------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension | +| **Mutation** | Direct mutator methods for `.imagePullSecrets` and `.automountServiceAccountToken`; metadata editor | +| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) | +| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle | + +See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. + +## Building a ServiceAccount Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/serviceaccount" + +base := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-sa", + Namespace: owner.Namespace, + }, +} + +resource, err := serviceaccount.NewBuilder(base). + WithMutation(BaseTokenMutation(owner.Spec.Version)). + Build() +``` + +`Build()` returns an error if `Name` or `Namespace` is empty. + +Identity format: `v1/ServiceAccount//`. + +## Mutations + +Each mutation is a named `serviceaccount.Mutation` that receives a `*Mutator` and records edit intent through direct +methods. See [The Mutation System](../primitives.md#the-mutation-system) for the full model. + +```go +func BaseTokenMutation(version string) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "base-token", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("default-registry") + return nil + }, + } +} +``` + +For boolean conditions, chain `.When()` on the gate. See +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within a single mutation, edits are applied in this fixed category order regardless of the call order: + +| Step | Category | What it affects | +| ---- | ----------------------- | --------------------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ServiceAccount` | +| 2 | Image pull secret edits | `.imagePullSecrets`: `EnsureImagePullSecret`, `RemoveImagePullSecret` | +| 3 | Automount edits | `.automountServiceAccountToken`: `SetAutomountServiceAccountToken` | + +Within each category, edits apply in registration order. Later features observe the object as modified by all earlier +ones. + +## Relevant Editors + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. See [Mutation Editors](../primitives.md#mutation-editors) for the general +editor model. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("managed-by", "my-operator") + return nil +}) +``` + +## Mutator Methods + +The `*serviceaccount.Mutator` exposes direct methods that bypass a nested editor for the two ServiceAccount-specific +fields. + +### EnsureImagePullSecret + +Adds a named image pull secret to `.imagePullSecrets` if not already present. Idempotent: calling it with an +already-present name is a no-op. + +```go +m.EnsureImagePullSecret("registry-creds") +``` + +### RemoveImagePullSecret + +Removes a named image pull secret from `.imagePullSecrets`. No-op if the name is not present. + +```go +m.RemoveImagePullSecret("old-registry-creds") +``` + +### SetAutomountServiceAccountToken + +Sets `.automountServiceAccountToken`. Pass `nil` to unset the field. + +```go +v := false +m.SetAutomountServiceAccountToken(&v) +``` + +The pointed-to value is snapshotted at registration time, so later caller-side changes do not affect `Apply()`. + +## Data Extraction + +`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled ServiceAccount. +Use it to surface generated fields to other resources: + +```go +resource, err := serviceaccount.NewBuilder(base). + WithDataExtractor(func(sa corev1.ServiceAccount) error { + sharedState.ServiceAccountName = sa.Name + return nil + }). + Build() +``` + +## Full Example + +```go +func PullSecretMutation(version string) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "pull-secret", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("default-registry") + return nil + }, + } +} + +func DisableAutomountMutation(version string, disable bool) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "disable-automount", + Feature: feature.NewVersionGate(version, nil).When(disable), + Mutate: func(m *serviceaccount.Mutator) error { + v := false + m.SetAutomountServiceAccountToken(&v) + return nil + }, + } +} + +resource, err := serviceaccount.NewBuilder(base). + WithMutation(PullSecretMutation(owner.Spec.Version)). + WithMutation(DisableAutomountMutation(owner.Spec.Version, owner.Spec.DisableAutomount)). + Build() +``` + +When `DisableAutomount` is true, `.automountServiceAccountToken` is set to `false`. When the condition is not met, the +field stays at its baseline value. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use +`feature.NewVersionGate(version, constraints)` when version gating is needed, and chain `.When(bool)` for boolean +conditions. + +**Use `EnsureImagePullSecret` for idempotent secret registration.** Multiple features can independently ensure their +required pull secrets without conflicting. + +**Register mutations in dependency order.** If one mutation depends on a field set by another, register the dependency +first. + +**ServiceAccount is genuinely simple.** The `*Mutator` exposes direct methods rather than a nested editor because the +only mutable fields are `.imagePullSecrets` and `.automountServiceAccountToken`. For anything beyond those fields, use +`EditObjectMetadata`. diff --git a/plugin/skills/using-primitives/references/primitives/statefulset.md b/plugin/skills/using-primitives/references/primitives/statefulset.md new file mode 100644 index 00000000..bfa3ae0b --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/statefulset.md @@ -0,0 +1,296 @@ +# StatefulSet Primitive + +The `statefulset` primitive wraps a Kubernetes `StatefulSet` and provides health tracking, suspension, volume claim +template management, and a typed mutation API for managing replicas, pod spec, and containers as part of the component +lifecycle. + +## Capabilities + +| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values | +| ------------------------------------------------------------ | ------------------------------------------------------- | +| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` | +| `Graceful` | `Healthy`, `Degraded`, `Down` | +| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | +| `Guardable` | `Blocked` | +| `DataExtractable` | _(side-effecting, no status)_ | + +## Building a StatefulSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + +base := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db", + Namespace: owner.Namespace, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: "db-headless", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "db"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "db"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "db", Image: "postgres:15"}, + }, + }, + }, + }, +} + +resource, err := statefulset.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Each mutation is a named `statefulset.Mutation` that receives a `*statefulset.Mutator` and records edits through typed +editors. + +```go +func StorageMutation(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "storage-backend", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *statefulset.Mutator) error { + m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"}) + return nil + }) + return nil + }, + } +} +``` + +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). + +## Internal Mutation Ordering + +Within each feature, edits run in this fixed category order: + +| Step | Category | What it affects | +| ---- | -------------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `StatefulSet` object | +| 2 | StatefulSetSpec edits | Replicas, service name, update strategy, etc. | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | +| 9 | Volume claim template operations | Adding or removing entries from `spec.volumeClaimTemplates` | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature. + +## Relevant Editors + +For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and +[container selectors](../primitives.md#container-selectors). + +### StatefulSetSpecEditor + +Controls statefulset-level settings via `m.EditStatefulSetSpec`. + +Available methods: `SetReplicas`, `SetServiceName`, `SetPodManagementPolicy`, `SetUpdateStrategy`, +`SetRevisionHistoryLimit`, `SetMinReadySeconds`, `SetPersistentVolumeClaimRetentionPolicy`, `Raw`. + +```go +m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(3) + e.SetServiceName("db-headless") + e.SetPodManagementPolicy(appsv1.ParallelPodManagement) + return nil +}) +``` + +Use `Raw()` for fields the typed API does not cover: + +```go +m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.Raw().UpdateStrategy = appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + } + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("db-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "db-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a +[container selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"}) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("2Gi")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` for the `StatefulSet` itself or `m.EditPodTemplateMetadata` +for the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("prometheus.io/scrape", "true") + return nil +}) +``` + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureReplicas(n)` | `EditStatefulSetSpec` → `SetReplicas(n)` | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Workload-Kind-Agnostic Mutations + +A mutation written against `primitives.WorkloadMutator` can be applied to a StatefulSet builder using +`statefulset.LiftMutation`. This lets one emitter function target StatefulSets, Deployments, and DaemonSets without +duplicating code. + +```go +backend.WithMutation(statefulset.LiftMutation(sharedAuthMutation())) +``` + +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the full pattern. + +## Volume Claim Templates + +`EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` manage persistent storage templates: + +```go +m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, +}) +``` + +!!! warning "VolumeClaimTemplates are immutable after creation" + + `spec.volumeClaimTemplates` cannot be changed once the StatefulSet exists in the cluster; the API server rejects + such updates. The mutator silently skips these operations on existing StatefulSets (identified by a non-empty + `ResourceVersion`). Plan your storage layout before the first creation. + +## Suspension + +When the component is suspended, the StatefulSet is scaled to zero replicas. The resource is not deleted. + +- `DefaultSuspendMutationHandler` calls `EnsureReplicas(0)`. +- `DefaultSuspensionStatusHandler` reports `Suspending` while `Status.Replicas > 0`, then `Suspended`. +- `DefaultDeleteOnSuspendHandler` returns `false`. + +Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision` +on the builder. + +## Full Example + +```go +func DatabaseMutation(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "database-storage", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *statefulset.Mutator) error { + m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(3) + e.SetPodManagementPolicy(appsv1.OrderedReadyPodManagement) + return nil + }) + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("50Gi"), + }, + }, + }, + }) + + m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "data", + MountPath: "/var/lib/postgresql/data", + }) + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**Use a StatefulSet for stateful workloads requiring pod identity.** StatefulSets provide stable network identities +(`pod-0`, `pod-1`, ...) and support VolumeClaimTemplates. For stateless workloads where pod identity does not matter, a +Deployment is simpler. + +**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use +`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean +conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +Internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so selectors in the +same mutation resolve correctly and reconciliation remains idempotent. + +**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. Changing the templates +requires recreating the StatefulSet. diff --git a/plugin/skills/using-primitives/references/primitives/unstructured.md b/plugin/skills/using-primitives/references/primitives/unstructured.md new file mode 100644 index 00000000..93368655 --- /dev/null +++ b/plugin/skills/using-primitives/references/primitives/unstructured.md @@ -0,0 +1,341 @@ +# Unstructured Primitives + +The unstructured primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type +definition at compile time: external CRDs, Crossplane resources, or any object known only at runtime. + +## When to Use Unstructured + +Choose between the three approaches in this order: + +1. **Typed primitive** (`pkg/primitives/`): use this whenever a built-in primitive covers your kind. It has the + most safety, the richest editor API, and the best domain defaults. +2. **Unstructured primitive** (this page): use this when the object's kind has no corresponding Go type or when you want + to manage an external CRD without generating Go client code. You supply all lifecycle semantics through required + handlers. +3. **Custom resource wrapper** (`pkg/generic`): use this when you own the Go type (your own CRD) or want a fully typed + mutation surface with a custom builder API. See the [Custom Resource Implementation Guide](../custom-resource.md). + +See also [Unstructured Primitives](../primitives.md#unstructured-primitives) in the Primitives Overview for a summary +table and [Implementing a Custom Resource](../primitives.md#implementing-a-custom-resource) for the full walkthrough. + +## Variants + +One variant exists per [lifecycle category](../primitives.md#primitive-categories), each implementing the corresponding +interfaces. Status values below are the runtime strings that appear in conditions (see +[Lifecycle Interfaces](../primitives.md#lifecycle-interfaces)). + +| Package | Category | Lifecycle interfaces | Required at `Build()` | +| ----------------------------------------- | ----------- | ------------------------------------------------------------------------ | ----------------------------- | +| `pkg/primitives/unstructured/static` | Static | `Guardable`, `DataExtractable` | _(none)_ | +| `pkg/primitives/unstructured/workload` | Workload | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | `WithCustomConvergeStatus` | +| `pkg/primitives/unstructured/integration` | Integration | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | `WithCustomOperationalStatus` | +| `pkg/primitives/unstructured/task` | Task | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | `WithCustomConvergeStatus` | + +## No Semantic Defaults + +Because the framework has no type information for unstructured objects, it infers no domain-specific status or +suspension behavior. Safe fallbacks are configured instead: + +- Grace status defaults to `Healthy` when no handler is provided. +- Suspension status defaults to `Suspended` immediately (no-op suspend mutation, `DeleteOnSuspend` returns `false`). + +Only the converge or operational status handler is required. All other handlers are optional. Calling `Build()` without +the required handler returns an error. + +## Building Unstructured Primitives + +### Static (simplest) + +```go +import ( + "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/static" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +obj := &uns.Unstructured{} +obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "example.io", Version: "v1alpha1", Kind: "Widget", +}) +obj.SetName("my-widget") +obj.SetNamespace(owner.Namespace) + +resource, err := static.NewBuilder(obj). + WithMutation(RegionMutation(owner.Spec.Version, owner.Spec.Region)). + Build() +``` + +### Workload + +```go +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/workload" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +resource, err := workload.NewBuilder(obj). + WithCustomConvergeStatus(func(op concepts.ConvergingOperation, o *uns.Unstructured) (concepts.AliveStatusWithReason, error) { + ready, _, _ := uns.NestedBool(o.Object, "status", "ready") + if ready { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "resource is ready", + }, nil + } + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "waiting for readiness", + }, nil + }). + Build() +``` + +### Integration + +```go +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/integration" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +resource, err := integration.NewBuilder(obj). + WithCustomOperationalStatus(func(op concepts.ConvergingOperation, o *uns.Unstructured) (concepts.OperationalStatusWithReason, error) { + phase, _, _ := uns.NestedString(o.Object, "status", "phase") + switch phase { + case "Ready": + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil + case "Pending": + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusPending}, nil + default: + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing, Reason: phase}, nil + } + }). + Build() +``` + +### Cluster-Scoped Resources + +Call `MarkClusterScoped()` for resources without a namespace. The builder rejects a non-empty namespace and formats the +identity string without a namespace segment. See [Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives) +for details. + +```go +resource, err := static.NewBuilder(obj). + MarkClusterScoped(). + Build() +``` + +## Mutations + +All four variants share `unstruct.Mutation` and `*unstruct.Mutator` from the parent `pkg/primitives/unstructured` +package. Mutations follow the same pattern as typed primitives. For a full explanation of the mutation system, +boolean-gated mutations, and version-gated mutations see [The Mutation System](../primitives.md#the-mutation-system), +[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and +[Version-Gated Mutations](../primitives.md#version-gated-mutations). + +```go +import ( + unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/feature" +) + +func RegionMutation(version, region string) unstruct.Mutation { + return unstruct.Mutation{ + Name: "set-region", + Feature: feature.NewVersionGate(version, nil), + Mutate: func(m *unstruct.Mutator) error { + m.EditContent(func(e *editors.UnstructuredContentEditor) error { + return e.SetNestedString(region, "spec", "forProvider", "region") + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("region", region) + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edits execute in a fixed category order regardless of the order they are recorded: + +| Step | Category | What it affects | +| ---- | -------------- | --------------------------------------------- | +| 1 | Metadata edits | Labels and annotations via `ObjectMetaEditor` | +| 2 | Content edits | Nested fields via `UnstructuredContentEditor` | + +Features apply in registration order. Later features observe the object as modified by all earlier ones. + +## Relevant Editors + +For the full method list of any editor see the +[Go API reference](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). The +generic concept is explained in [Mutation Editors](../primitives.md#mutation-editors). + +### UnstructuredContentEditor + +The `UnstructuredContentEditor` wraps the object's `map[string]interface{}` content and provides structured operations +for setting and removing values at nested paths. Access it via `m.EditContent`. + +| Method | Signature | Purpose | +| ---------------------------- | -------------------------------------------------------- | ---------------------------------------------- | +| `SetNestedField` | `(value interface{}, fields ...string) error` | Set any value at a nested path | +| `RemoveNestedField` | `(fields ...string)` | Remove a field at a nested path | +| `SetNestedString` | `(value string, fields ...string) error` | Convenience for string fields | +| `SetNestedBool` | `(value bool, fields ...string) error` | Convenience for boolean fields | +| `SetNestedInt64` | `(value int64, fields ...string) error` | Convenience for integer fields | +| `SetNestedFloat64` | `(value float64, fields ...string) error` | Convenience for float fields | +| `SetNestedStringMap` | `(value map[string]string, fields ...string) error` | Set a string map (labels, selectors) | +| `EnsureNestedStringMapEntry` | `(key, value string, fields ...string) error` | Add or update one entry in a nested string map | +| `RemoveNestedStringMapEntry` | `(key string, fields ...string) error` | Remove one entry from a nested string map | +| `SetNestedSlice` | `(value []interface{}, fields ...string) error` | Set an entire slice | +| `SetNestedMap` | `(value map[string]interface{}, fields ...string) error` | Set an entire sub-object | +| `Raw` | `() map[string]interface{}` | Escape hatch for free-form access | + +When the structured methods are insufficient, `Raw()` returns the underlying content map for direct manipulation: + +```go +m.EditContent(func(e *editors.UnstructuredContentEditor) error { + raw := e.Raw() + spec, ok := raw["spec"].(map[string]interface{}) + if !ok { + spec = map[string]interface{}{} + raw["spec"] = spec + } + spec["customField"] = someComplexValue + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +!!! note "Metadata bridging" + + `*uns.Unstructured` does not embed `metav1.ObjectMeta`. During `Apply()`, the mutator populates a temporary + `ObjectMeta` from the object's labels and annotations, runs the editor, and writes the results back via + `SetLabels`/`SetAnnotations`. The behavior is identical to typed primitives from the caller's perspective. + +## Identity + +The identity string is derived from the object's GVK, namespace, and name at build time: + +- Namespaced: `{group}/{version}/{kind}/{namespace}/{name}` +- Cluster-scoped: `{group}/{version}/{kind}/{name}` + +Namespaced resources must have a non-empty namespace; `Build()` rejects empty namespaces unless `MarkClusterScoped()` +was called. + +## Data Extraction + +All four variants support data extraction. The extractor receives a value copy of the reconciled object after each sync +cycle: + +```go +builder.WithDataExtractor(func(obj uns.Unstructured) error { + ip, found, _ := uns.NestedString(obj.Object, "status", "atProvider", "ipAddress") + if found { + myComponent.ResourceIP = ip + } + return nil +}) +``` + +## Suspension Handlers + +The non-static variants support custom suspension behavior. All three handlers default to safe no-ops when omitted. + +| Builder method | Default behavior | +| ----------------------------------- | ----------------------------------- | +| `WithCustomSuspendDeletionDecision` | Returns `false` (keep the resource) | +| `WithCustomSuspendMutation` | No-op (no spec changes on suspend) | +| `WithCustomSuspendStatus` | Reports `Suspended` immediately | + +Override them when the resource has native suspend semantics or must be deleted on suspend: + +```go +workload.NewBuilder(obj). + WithCustomSuspendDeletionDecision(func(o *uns.Unstructured) bool { + return true // delete on suspend + }). + WithCustomSuspendMutation(func(m *unstruct.Mutator) error { + return nil // no-op; deletion handles everything + }). + WithCustomSuspendStatus(func(o *uns.Unstructured) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + }). + Build() +``` + +## Full Example + +```go +// Manage an external CRD that provisions a database connection. +obj := &uns.Unstructured{} +obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "db.example.io", Version: "v1", Kind: "Connection", +}) +obj.SetName("app-db") +obj.SetNamespace(owner.Namespace) + +resource, err := integration.NewBuilder(obj). + WithMutation(unstruct.Mutation{ + Name: "connection-config", + Feature: feature.NewVersionGate(owner.Spec.Version, nil), + Mutate: func(m *unstruct.Mutator) error { + m.EditContent(func(e *editors.UnstructuredContentEditor) error { + if err := e.SetNestedString(owner.Spec.Region, "spec", "region"); err != nil { + return err + } + return e.SetNestedInt64(int64(owner.Spec.PoolSize), "spec", "poolSize") + }) + return nil + }, + }). + WithCustomOperationalStatus(func(_ concepts.ConvergingOperation, o *uns.Unstructured) (concepts.OperationalStatusWithReason, error) { + phase, _, _ := uns.NestedString(o.Object, "status", "phase") + switch phase { + case "Ready": + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil + case "Provisioning": + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusPending, Reason: "provisioning"}, nil + default: + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing, Reason: phase}, nil + } + }). + WithDataExtractor(func(o uns.Unstructured) error { + endpoint, _, _ := uns.NestedString(o.Object, "status", "endpoint") + myComponent.DBEndpoint = endpoint + return nil + }). + Build() +``` + +## Guidance + +**Choose the right variant.** Pick the variant that matches the object's runtime behavior. Use `workload` for +long-running objects with observable health, `integration` for objects whose readiness depends on an external +controller, `task` for objects that run to completion, and `static` for configuration-like objects. + +**Handlers encode all lifecycle semantics.** The framework has no type information for unstructured objects. The +handlers you provide are the sole source of lifecycle semantics. Inspect `obj.Object` fields directly to determine +status. + +**Prefer typed primitives when possible.** Unstructured primitives trade compile-time safety for runtime flexibility. If +a built-in typed primitive covers the kind, use it. + +**Test handlers thoroughly.** Without domain-specific defaults as a safety net, handler correctness is entirely on the +operator author. Write table-driven tests covering all status transitions before deploying. + +**Use typed primitives or custom resource wrappers for your own CRDs.** Unstructured primitives are intended for +third-party or generated resources where a Go type is unavailable. For your own CRDs, generate the Go type and use a +typed wrapper; see the [Custom Resource Implementation Guide](../custom-resource.md). From 0e3dff602a24d74eef22c5a0cfec1dfbebef6f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:43:11 +0200 Subject: [PATCH 03/21] feat(plugin): building-components skill Co-Authored-By: Claude Fable 5 --- plugin/skills/building-components/SKILL.md | 181 +++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 plugin/skills/building-components/SKILL.md diff --git a/plugin/skills/building-components/SKILL.md b/plugin/skills/building-components/SKILL.md new file mode 100644 index 00000000..9365c30e --- /dev/null +++ b/plugin/skills/building-components/SKILL.md @@ -0,0 +1,181 @@ +--- +name: building-components +description: + Use when creating or modifying a component built with the operator-component-framework + (github.com/sourcehawk/operator-component-framework) - covers the component builder, resource registration, feature + gates, prerequisites, the reconciliation lifecycle, conditions and the status model, grace periods, suspension, + ReconcileContext, FlushStatus, and guards. +--- + +# Building Components + +## What a component is + +A **Component** groups related Kubernetes resources into one behavioral unit. It reconciles those resources, manages +their shared lifecycle (feature gating, prerequisites, suspension, grace periods, guards), and reports their aggregate +health through a single condition on the owner CRD. + +Layering: `Controller` -> `Component` (one condition on the owner) -> `Resource Primitive` (Deployment, ConfigMap, +Service, ...) -> `Kubernetes Object`. A controller wires together several components; each component owns exactly one +condition type and one ordered list of resources. + +## Building a component + +Components are constructed through `component.NewComponentBuilder()`. `Build()` requires `WithName` and +`WithConditionType`; omitting either is a validation error aggregated with `errors.Join` and returned from `Build()`. +Resources are added with `WithResource`. The owner CRD itself is not passed to the builder: it flows into reconciliation +later through `ReconcileContext.Owner`, and resource constructors typically close over it when building the +desired-state object passed to `WithResource`. + +```go +comp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithFeatureGate(frontendFeature). // optional: disable to remove all resources + WithPrerequisite(component.DependsOn("BackendReady")). // optional: wait for another component + WithResource(frontendConfig, component.ReadOnly()). + WithResource(frontendDeployment). + WithResource(frontendService). + WithResource(legacyService, component.Delete()). + WithGracePeriod(5 * time.Minute). + Suspend(owner.Spec.Suspended). + Build() +if err != nil { + return err +} +``` + +Each `WithResource` call accepts `ResourceOption` values: `component.ReadOnly()`, `component.Delete()` / +`component.DeleteWhen(cond)`, `component.GatedBy(gate)`, `component.OrphanWhen(cond)`, `component.Unowned()`, +`component.Auxiliary()`, `component.BlockOnAbsence()`, `component.IgnoreIfAbsent()`. With no options a resource is +**Managed**: applied via Server-Side Apply, required for the condition. `ReadOnly()` is mutually exclusive with the +deletion and gating options. See `references/component.md` for the full option matrix and `IncludeWhen` vs. `GatedBy`. + +## Registration order is execution order + +Resources reconcile sequentially in the order they were registered with `WithResource`. A resource registered earlier +can populate data (via a data extractor) that a later resource's guard or mutation depends on; the reverse never works. +Resources registered for deletion (`Delete()`, `DeleteWhen()`, or all managed resources when a feature gate is disabled) +are removed from the cluster in that same registration order in the final reconciliation step; the framework does not +reverse it. Design registration order around real dependencies, not convenience. + +## Feature gates and prerequisites + +A component-level feature gate (`WithFeatureGate`) controls whether the component is active at all. When disabled, the +component deletes every resource it manages and reports condition status `True` with reason `Disabled`. If the gate's +`Enabled()` call itself errors, the component reports reason `FeatureGateError` instead, distinguishing a +gate-evaluation failure from a normal disabled state. + +Prerequisites (`WithPrerequisite`, satisfied by the `Prerequisite` interface or the built-in `component.DependsOn` +helper) are initialization barriers, not ongoing health checks. They answer "can this component be created?", not +"should this component keep running?". The barrier is active only while the component's condition reason is `Unknown`, +`PrerequisiteNotMet`, `Disabled`, or `FeatureGateError`; once the reason becomes anything else the barrier is +permanently passed and never re-evaluated, even if the dependency later becomes unhealthy. Multiple prerequisites are +checked in registration order and the first unmet one short-circuits the rest, setting condition status `False` with +reason `PrerequisiteNotMet`. The feature gate check always runs first: a disabled gate skips prerequisite evaluation +entirely. + +## Reconciliation lifecycle + +`comp.Reconcile(ctx, recCtx)` runs these steps, in order, every call: + +1. **Feature gate check.** Disabled -> delete all managed resources, condition `True/Disabled`. Gate error -> + `FeatureGateError`, no further steps. +2. **Prerequisite check.** Only while the initialization barrier is active. Unmet -> condition + `False/PrerequisiteNotMet`, no resources touched. +3. **Suspension check.** If suspended, `Suspend()` runs on managed resources, the condition reflects suspension + progress, pending deletions are processed, and reconciliation stops there. Guards are not evaluated during + suspension. +4. **Resource reconciliation.** Non-delete resources are processed sequentially in registration order: each resource's + guard (if any) is checked, a blocked guard halts that resource and every later one, then the resource is applied + (managed) or fetched (read-only), and its data extractors run immediately so later resources can see the extracted + data. +5. **Status aggregation.** The converging status of every processed resource (including a blocked-guard result) is + collected. +6. **Condition update.** A new condition is derived from the aggregate status, the previous condition, and the grace + period, then written to the owner **in memory only**. `Reconcile` never calls the Kubernetes status API. +7. **Resource deletion.** Resources registered for deletion are removed from the cluster. + +Every resource defaults to `ParticipationModeRequired`: its health must reach a ready state for the component condition +to go `True`. Register a resource with `component.Auxiliary()` to exclude its health from aggregation (a blocked guard +on it still halts the pipeline and still contributes, because a blocked guard stops everything after it). + +## Status model + +A component reports one condition whose reason is a `component.Status` value. Reachable states depend on which lifecycle +interface a resource implements: long-running workloads report `Alive` states (`Creating`, `Updating`, `Scaling`, +`Healthy`, `AliveFailing`), run-to-completion resources report `Completable` states (`CompletionPending`, +`CompletionRunning`, `Completed`, `CompletionFailing`), externally-dependent resources report `Operational` states +(`OperationPending`, `Operational`, `OperationFailing`), and resources implementing none of these are ready as long as +they exist. When a component aggregates several resources into one condition, `Status.Priority()` picks the +highest-priority reason: `Error` and `FeatureGateError` outrank everything, then grace-expired states (`Down`, +`Degraded`), then suspension states (`PendingSuspension`, `Suspending`, `Suspended`), then `Disabled`, then the various +failing/converging/pending states, then the ready states (`Healthy`, `Operational`, `Completed`) at the bottom. +`Unknown` and any unrecognized reason are priority `0` and never influence aggregation. A resource registered with +`component.Auxiliary()` does not contribute its converging health to this aggregation, but a blocked guard on it still +does. + +`Reconcile` only stages the condition on the in-memory owner object; it is not the writer of record to the cluster. + +## Grace period and suspension + +`WithGracePeriod` defines how long a component may remain in a converging state (`Creating`, `Updating`, `Scaling`) +before escalating. This is a **convergence-time budget, not an error budget**: during the grace period the component +reports its real converging state, not a failure, so rolling updates and normal scale-ups do not trip false alerts. Once +the period expires and the component is still not ready, a `Graceful` resource's `GraceStatus()` determines the +post-expiry severity: `Healthy` (no issue), `Degraded` (partially functional), or `Down` (non-functional). Exceeding the +grace period does not by itself mean failure; it means the component's own `GraceStatus()` is now consulted to decide +whether the still-not-ready state is degraded, down, or actually fine. + +Suspension (`Suspend(true)` on the builder) intentionally deactivates a component without deleting its configuration. +The component calls `Suspend()` on every `Suspendable` resource, polls `SuspensionStatus()`, and progresses the +condition through `PendingSuspension` -> `Suspending` -> `Suspended` (all condition status `True`). While the +prerequisite barrier is active, suspension is a no-op, since no resources exist yet to suspend. Resources not yet in the +cluster are created directly in their suspended state (for example, a Deployment created with zero replicas), so they +are ready the instant suspension ends. + +## Guards and ReconcileContext + +A guard is a precondition function registered on a resource with `WithGuard`, evaluated before that resource is applied. +It receives a copy of the resource's object and returns a `concepts.GuardStatusWithReason`. If it returns +`GuardStatusBlocked`, that resource and every resource registered after it are skipped for the cycle, and the condition +reports status `False` with reason `Blocked`. Combined with a data extractor on an earlier resource, guards let resource +B wait on a value resource A only produces once applied, without either resource knowing about the other's type. A guard +evaluation error is treated as a reconciliation failure (`Error`). Guards are not evaluated during suspension. + +`ReconcileContext` carries everything a reconcile pass needs: `Client`, `Scheme`, `Recorder`, an optional `Metrics` +recorder, and `Owner` (the CRD instance that owns the component). Build one per reconcile from your controller and pass +it into `comp.Reconcile(ctx, recCtx)`. + +`Component.Reconcile` mutates the owner's status conditions only in memory. The controller persists them by calling +`component.FlushStatus(ctx, recCtx)` once per reconcile, typically deferred so conditions set on error paths are still +written. `FlushStatus` performs a single `Status().Update`, wrapped in `retry.RetryOnConflict`, that writes every +condition currently staged on the owner; conditions owned by other writers on the same object are preserved because +`meta.SetStatusCondition` merges by condition type. This split lets a controller with several components stage several +conditions in one reconcile and persist them in one write, instead of racing a write per component. + +## Anti-patterns + +- Putting version-dependent values directly in a resource's desired state instead of behind a versioned mutation makes + the resource diverge unpredictably across cluster versions; gate the value with a mutation instead. +- Giving one component more than one logical condition collapses distinct failure modes into a single status, hiding + which underlying concern actually broke; split it into one component per condition instead. +- Registering resources without regard to their real dependency order silently breaks guards and data extractors that + assume an earlier resource already ran; registration order must match the actual dependency order. + +## Ground truth + +The consumer's resolved module version is the source of truth, not these docs. Before asserting an exact signature, +method name, or option: + +1. Read the framework version from the consumer's `go.mod` entry for + `github.com/sourcehawk/operator-component-framework`. +2. Verify the symbol with `go doc github.com/sourcehawk/operator-component-framework/pkg/ `. + +The reference files bundled with this skill match the framework version this plugin shipped with. When they disagree +with `go doc`, `go doc` wins. + +## References + +- `references/component.md`: full component documentation. Read when you need exact builder signatures, status + constants, lifecycle phase details, or guard semantics. From d4b4342bbc77956d15596e7bfb7093ac2723a321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:51:10 +0200 Subject: [PATCH 04/21] docs(component): state deletion ordering and required builder inputs Co-Authored-By: Claude Fable 5 --- docs/component.md | 7 ++++++- plugin/skills/building-components/references/component.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/component.md b/docs/component.md index b4fba04f..910499b7 100644 --- a/docs/component.md +++ b/docs/component.md @@ -26,6 +26,10 @@ For operator-structuring advice (one component per condition, thin controllers, Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle flags, then produces an immutable `Component` ready for reconciliation. +`Build()` requires `WithName` and `WithConditionType`; every other builder method is optional. If either is missing, or +any registered resource fails validation, `Build()` returns a single aggregated error containing every failure, using +`errors.Join`. + ```go comp, err := component.NewComponentBuilder(). WithName("frontend"). @@ -253,7 +257,8 @@ message: 6. **Condition update.** A new component condition is derived from the aggregate resource status, the previous condition, and the configured grace period, then written to the owner **in memory only**. `Reconcile` never calls the Kubernetes status API; the controller persists with [`FlushStatus`](#persisting-status-with-flushstatus). -7. **Resource deletion.** Resources registered for deletion are removed from the cluster. +7. **Resource deletion.** Resources registered for deletion are removed from the cluster, in the same registration order + used for reconciliation; the framework does not reverse it. ```mermaid flowchart TD diff --git a/plugin/skills/building-components/references/component.md b/plugin/skills/building-components/references/component.md index b4fba04f..910499b7 100644 --- a/plugin/skills/building-components/references/component.md +++ b/plugin/skills/building-components/references/component.md @@ -26,6 +26,10 @@ For operator-structuring advice (one component per condition, thin controllers, Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle flags, then produces an immutable `Component` ready for reconciliation. +`Build()` requires `WithName` and `WithConditionType`; every other builder method is optional. If either is missing, or +any registered resource fails validation, `Build()` returns a single aggregated error containing every failure, using +`errors.Join`. + ```go comp, err := component.NewComponentBuilder(). WithName("frontend"). @@ -253,7 +257,8 @@ message: 6. **Condition update.** A new component condition is derived from the aggregate resource status, the previous condition, and the configured grace period, then written to the owner **in memory only**. `Reconcile` never calls the Kubernetes status API; the controller persists with [`FlushStatus`](#persisting-status-with-flushstatus). -7. **Resource deletion.** Resources registered for deletion are removed from the cluster. +7. **Resource deletion.** Resources registered for deletion are removed from the cluster, in the same registration order + used for reconciliation; the framework does not reverse it. ```mermaid flowchart TD From f4a57bba8066ddcae8aa58cf47a41f30bb118c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:57:01 +0200 Subject: [PATCH 05/21] feat(plugin): using-primitives skill Co-Authored-By: Claude Fable 5 --- plugin/skills/using-primitives/SKILL.md | 238 ++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 plugin/skills/using-primitives/SKILL.md diff --git a/plugin/skills/using-primitives/SKILL.md b/plugin/skills/using-primitives/SKILL.md new file mode 100644 index 00000000..97e4ead1 --- /dev/null +++ b/plugin/skills/using-primitives/SKILL.md @@ -0,0 +1,238 @@ +--- +name: using-primitives +description: + Use when creating or editing Kubernetes resource primitives with the operator-component-framework - primitive builders + and categories, baseline desired state, the mutation system, boolean and version feature gating (NewBooleanGate, + NewVersionGate), mutation editors, container selectors, server-side apply behaviour, workload-kind-agnostic mutations + (WorkloadMutator), and unstructured primitives. +--- + +# Using Primitives + +## What a primitive is + +A primitive wraps a specific Kubernetes kind (`Deployment`, `ConfigMap`, and so on) and encapsulates a desired-state +baseline, a mutation surface, lifecycle integration (readiness, grace handling, suspension), and Server-Side Apply. +Every primitive implements `component.Resource` and may implement one or more lifecycle interfaces to participate in a +component's status aggregation. + +The framework groups primitives into four categories by runtime behavior, and the category determines which lifecycle +interfaces a primitive implements: + +- **Static** — `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`. Desired state is mostly + fixed; ready as soon as it exists. +- **Workload** — `Deployment`, `StatefulSet`, `DaemonSet`. Long-running processes requiring runtime convergence; + implement `Alive`, `Graceful`, and `Suspendable`. +- **Task** — `Job`. Short-lived operations that run to completion; implement `Completable` and `Suspendable`. +- **Integration** — `Service`, `Ingress`, `CronJob`, `HPA`. Readiness depends on a controller the operator does not own; + implement `Operational`, and may also implement `Graceful` or `Suspendable`. + +## Baseline plus mutations + +This is the framework's central idiom: **the baseline object holds version-independent desired state; every +version-dependent or optional field is applied by a named mutation.** The object you hand a builder (for example +`deployment.NewBuilder(base)`) represents only the shape that never changes across versions or feature toggles. Anything +that depends on the owner's spec version, a feature flag, or a runtime condition belongs in a mutation registered with +`WithMutation`, never hardcoded into the baseline. + +```go +base := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "web-server", Namespace: owner.Namespace}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "web"}}}, + }, + }, +} + +resource, err := deployment.NewBuilder(base). + WithMutation(ConfigMutation(owner.Spec.Version)). + Build() +``` + +Keeping version-dependent fields out of the baseline is what makes gating, golden testing, and mutation composition +work: a mutation can be introspected, asserted on by name, and enabled or disabled independently of every other mutation +touching the same resource. A baseline that already contains a version-specific value cannot be gated, tested in +isolation, or turned off for an older owner version. + +## The mutation system + +A mutation is a `feature.Mutation[T]`, where `T` is the primitive's mutator type: + +```go +type Mutation[T any] struct { + Name string // unique within the resource; used in gating and error reporting + Feature Gate // optional; nil means apply unconditionally + Mutate func(T) error +} +``` + +Each primitive package defines its own concrete alias (`deployment.Mutation`, `configmap.Mutation`, and so on) over this +generic type. Register mutations with `WithMutation`, which preserves registration order and is a no-op when called with +no arguments. Mutation names must be unique within a resource: `Build()` fails if two mutations share a `Name`, because +the name is what gating and error reporting — and the framework's golden test tooling — refer to. + +Mutations do not touch the Kubernetes object directly. Each `Mutate` records intent through typed editors, and the +framework replays every recorded edit in a single controlled pass during `Apply()`. Features apply in registration +order; within one feature's pass, edits run in a fixed category order (object metadata, spec, pod-template metadata, pod +spec, container presence, container edits, init-container presence, init-container edits for pod-workload kinds) +regardless of the order methods were called inside `Mutate`. Later features observe the object as already modified by +earlier ones. + +**Boolean gates** (`feature.NewBooleanGate(cond)`) make a mutation conditional on a runtime value, typically a field in +the owner's spec: + +```go +gate := feature.NewBooleanGate(len(spec.ExtraEnv) > 0) +``` + +**Version gates** (`feature.NewVersionGate(currentVersion, constraints)`) enable a mutation only for versions matching +every `feature.VersionConstraint` in the slice. `NewBooleanGate` is shorthand for `NewVersionGate("", nil).When(b)`, and +version and boolean gating combine freely via `.When(...)`, since every condition must be true for the gate to enable: + +```go +func MetricsFeatureMutation(version string, enabled bool) configmap.Mutation { + return configmap.Mutation{ + Name: "metrics-feature", + Feature: feature.NewVersionGate(version, nil).When(enabled), + Mutate: func(m *configmap.Mutator) error { + return m.MergeYAML("app.yaml", "metrics:\n enabled: true\n port: 9090\n") + }, + } +} +``` + +A common pattern pairs mutually exclusive constraints (`>= V` and `< V`) so exactly one variant of a mutation fires for +any given version. + +## Editors and selectors + +Editors are scoped, typed APIs for modifying one part of a resource; a mutator hands one to your callback, you record +changes, the framework applies them during the plan-and-apply pass. Groups of editors: + +- **Container editors** (`ContainerEditor`) — env vars, args, resources, probes — selected by a container selector. +- **Pod-shaping editors** (`PodSpecEditor`, `ObjectMetaEditor`) shared by all pod-workload kinds. +- **Kind-specific spec editors** (`DeploymentSpecEditor`, `ServiceSpecEditor`, `IngressSpecEditor`, ...), one per kind. +- **Data editors** (`ConfigMapDataEditor`, `SecretDataEditor`) and **RBAC editors** (`PolicyRulesEditor`, + `BindingSubjectsEditor`). + +Every editor exposes `.Raw()`, returning a pointer to the underlying Kubernetes struct for fields the typed API does not +cover; using it is safe because the edit still stays scoped to that editor's target and runs inside the controlled apply +pass. Container selectors, in `pkg/mutation/selectors`, decide which containers an editor targets: `AllContainers()`, +`ContainerNamed(name)`, `ContainersNamed(names...)`, `ContainerNotNamed(name)`, `ContainersNotNamed(names...)`, +`ContainerAtIndex(i)`. A selector is evaluated against a snapshot taken at the start of the container phase, after that +feature's own presence operations, so one mutation can add a container and configure it in the same pass. + +For the full method surface of any editor or selector, see `pkg/mutation/editors` and `pkg/mutation/selectors`; each +per-kind reference file also documents the editors relevant to that kind. + +## Workload-kind-agnostic mutations + +`*deployment.Mutator`, `*statefulset.Mutator`, and `*daemonset.Mutator` share the same container, init-container, +pod-spec, pod-template-metadata, object-metadata, environment-variable, and argument editing methods. +`primitives.WorkloadMutator` is the interface covering exactly that shared surface. Reach for it when the same mutation +(a sidecar, an env var, a label) needs to apply across more than one workload kind: write the emitter once against +`primitives.WorkloadMutator`, then lift it onto each concrete builder with that package's `LiftMutation` adapter, which +carries `Name` and `Feature` through unchanged: + +```go +func authEnv() feature.Mutation[primitives.WorkloadMutator] { + return feature.Mutation[primitives.WorkloadMutator]{ + Name: "auth-env", + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "AUTH_MODE", Value: "oidc"}) + return nil + }, + } +} + +backend.WithMutation(statefulset.LiftMutation(authEnv())) +frontend.WithMutation(deployment.LiftMutation(authEnv())) +agent.WithMutation(daemonset.LiftMutation(authEnv())) +``` + +The interface deliberately omits what is not common to all three kinds: per-kind spec editors (`EditDeploymentSpec`, +`EditStatefulSetSpec`, `EditDaemonSetSpec`), `EnsureReplicas` (no replica field on DaemonSet), and StatefulSet-only +VolumeClaimTemplate methods. Reach for the concrete mutator type for those. + +## Server-side apply + +The framework reconciles with Server-Side Apply: each primitive builds its desired state (baseline plus all active +mutations) and patches it with `client.Apply`, sending only the fields the operator declares. Server-managed defaults +and fields set by other controllers or webhooks are left untouched. The field manager name is derived as +`"{Owner.GetKind()}/{componentName}"`, and the framework applies with forced ownership, taking control of conflicting +fields from other managers while leaving fields it does not include with their current owners. This is what lets +primitives coexist with other controllers touching the same resource without a perpetual-update fight over stripped +server defaults. + +## Cluster-scoped and unstructured primitives + +A primitive for a cluster-scoped kind (`ClusterRole`, `ClusterRoleBinding`, `PersistentVolume`) must call +`MarkClusterScoped()` on its `BaseBuilder`, which inverts the namespace check: the builder rejects a namespace instead +of requiring one, and the primitive's identity function omits the namespace segment. + +Unstructured primitives (`pkg/primitives/unstructured/{static,workload,integration,task}`) are the escape hatch for +Kubernetes objects with no Go type, for example external CRDs. One variant exists per category, implementing the +matching lifecycle interfaces; since the framework cannot know the object's semantics, the builders default to +generic-safe behavior (no grace handler means always `Healthy`; no suspension handler means `Suspended` with a no-op +mutation). All variants share a single `Mutator` and an `UnstructuredContentEditor` for nested-field edits. + +## Built-in primitives + +Each kind below has a per-kind reference file at `references/primitives/.md` documenting its builder, mutations, +editors, and suspension/lifecycle behavior. + +| Primitive | Category | Reference file | +| ----------------------------------- | ----------- | --------------------------------------------- | +| `pkg/primitives/deployment` | Workload | `references/primitives/deployment.md` | +| `pkg/primitives/statefulset` | Workload | `references/primitives/statefulset.md` | +| `pkg/primitives/replicaset` | Workload | `references/primitives/replicaset.md` | +| `pkg/primitives/daemonset` | Workload | `references/primitives/daemonset.md` | +| `pkg/primitives/pod` | Workload | `references/primitives/pod.md` | +| `pkg/primitives/job` | Task | `references/primitives/job.md` | +| `pkg/primitives/cronjob` | Integration | `references/primitives/cronjob.md` | +| `pkg/primitives/configmap` | Static | `references/primitives/configmap.md` | +| `pkg/primitives/secret` | Static | `references/primitives/secret.md` | +| `pkg/primitives/role` | Static | `references/primitives/role.md` | +| `pkg/primitives/rolebinding` | Static | `references/primitives/rolebinding.md` | +| `pkg/primitives/pdb` | Static | `references/primitives/pdb.md` | +| `pkg/primitives/clusterrole` | Static | `references/primitives/clusterrole.md` | +| `pkg/primitives/clusterrolebinding` | Static | `references/primitives/clusterrolebinding.md` | +| `pkg/primitives/serviceaccount` | Static | `references/primitives/serviceaccount.md` | +| `pkg/primitives/service` | Integration | `references/primitives/service.md` | +| `pkg/primitives/pv` | Integration | `references/primitives/pv.md` | +| `pkg/primitives/pvc` | Integration | `references/primitives/pvc.md` | +| `pkg/primitives/hpa` | Integration | `references/primitives/hpa.md` | +| `pkg/primitives/ingress` | Integration | `references/primitives/ingress.md` | +| `pkg/primitives/networkpolicy` | Static | `references/primitives/networkpolicy.md` | +| `pkg/primitives/unstructured/*` | all four | `references/primitives/unstructured.md` | + +## Anti-patterns + +- **Hand-writing a structural interface for a shared mutation instead of using `primitives.WorkloadMutator`.** + Duplicating the same emitter per kind (or inventing a narrower ad hoc interface) drifts as soon as one copy is edited + and the others are not; write it once against `WorkloadMutator` and lift it with `LiftMutation`. +- **Unnamed mutations.** An empty or reused `Name` collides at `Build()` and defeats the tooling that asserts which + mutations fire at which versions; always give a mutation a unique, descriptive name. +- **Putting gated values in the baseline.** A version-dependent or feature-dependent field written directly into the + baseline object cannot be toggled off, gated by version, or asserted on independently — it silently applies to every + owner version. Move it into a named, gated mutation instead. + +## Ground truth + +The consumer's resolved module version is the source of truth, not these docs. Before asserting an exact signature, +method name, or option: + +1. Read the framework version from the consumer's `go.mod` entry for + `github.com/sourcehawk/operator-component-framework`. +2. Verify the symbol with `go doc github.com/sourcehawk/operator-component-framework/pkg/ `. + +The reference files bundled with this skill match the framework version this plugin shipped with. When they disagree +with `go doc`, `go doc` wins. + +## References + +- `references/primitives.md`: concepts shared across every primitive — categories, lifecycle interfaces, the mutation + system, gating, editors, selectors, Server-Side Apply, cluster-scoped and unstructured primitives. +- `references/primitives/.md`: per-kind builders, mutations, editors, and suspension behavior. Read the specific + kind's file before writing a mutation against it. From 96e84c50ca0e5cdd95be431c2d9bad16ad769605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:03:12 +0200 Subject: [PATCH 06/21] fix(plugin): remove em dashes from using-primitives skill Co-Authored-By: Claude Fable 5 --- plugin/skills/using-primitives/SKILL.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/plugin/skills/using-primitives/SKILL.md b/plugin/skills/using-primitives/SKILL.md index 97e4ead1..a687ceb3 100644 --- a/plugin/skills/using-primitives/SKILL.md +++ b/plugin/skills/using-primitives/SKILL.md @@ -19,12 +19,12 @@ component's status aggregation. The framework groups primitives into four categories by runtime behavior, and the category determines which lifecycle interfaces a primitive implements: -- **Static** — `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`. Desired state is mostly +- **Static**: `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`. Desired state is mostly fixed; ready as soon as it exists. -- **Workload** — `Deployment`, `StatefulSet`, `DaemonSet`. Long-running processes requiring runtime convergence; +- **Workload**: `Deployment`, `StatefulSet`, `DaemonSet`. Long-running processes requiring runtime convergence; implement `Alive`, `Graceful`, and `Suspendable`. -- **Task** — `Job`. Short-lived operations that run to completion; implement `Completable` and `Suspendable`. -- **Integration** — `Service`, `Ingress`, `CronJob`, `HPA`. Readiness depends on a controller the operator does not own; +- **Task**: `Job`. Short-lived operations that run to completion; implement `Completable` and `Suspendable`. +- **Integration**: `Service`, `Ingress`, `CronJob`, `HPA`. Readiness depends on a controller the operator does not own; implement `Operational`, and may also implement `Graceful` or `Suspendable`. ## Baseline plus mutations @@ -70,7 +70,7 @@ type Mutation[T any] struct { Each primitive package defines its own concrete alias (`deployment.Mutation`, `configmap.Mutation`, and so on) over this generic type. Register mutations with `WithMutation`, which preserves registration order and is a no-op when called with no arguments. Mutation names must be unique within a resource: `Build()` fails if two mutations share a `Name`, because -the name is what gating and error reporting — and the framework's golden test tooling — refer to. +the name is what gating and error reporting refer to. Mutations do not touch the Kubernetes object directly. Each `Mutate` records intent through typed editors, and the framework replays every recorded edit in a single controlled pass during `Apply()`. Features apply in registration @@ -110,7 +110,8 @@ any given version. Editors are scoped, typed APIs for modifying one part of a resource; a mutator hands one to your callback, you record changes, the framework applies them during the plan-and-apply pass. Groups of editors: -- **Container editors** (`ContainerEditor`) — env vars, args, resources, probes — selected by a container selector. +- **Container editors** (`ContainerEditor`), for env vars, args, resources, and probes, selected by a container + selector. - **Pod-shaping editors** (`PodSpecEditor`, `ObjectMetaEditor`) shared by all pod-workload kinds. - **Kind-specific spec editors** (`DeploymentSpecEditor`, `ServiceSpecEditor`, `IngressSpecEditor`, ...), one per kind. - **Data editors** (`ConfigMapDataEditor`, `SecretDataEditor`) and **RBAC editors** (`PolicyRulesEditor`, @@ -215,7 +216,7 @@ editors, and suspension/lifecycle behavior. - **Unnamed mutations.** An empty or reused `Name` collides at `Build()` and defeats the tooling that asserts which mutations fire at which versions; always give a mutation a unique, descriptive name. - **Putting gated values in the baseline.** A version-dependent or feature-dependent field written directly into the - baseline object cannot be toggled off, gated by version, or asserted on independently — it silently applies to every + baseline object cannot be toggled off, gated by version, or asserted on independently: it silently applies to every owner version. Move it into a named, gated mutation instead. ## Ground truth @@ -232,7 +233,7 @@ with `go doc`, `go doc` wins. ## References -- `references/primitives.md`: concepts shared across every primitive — categories, lifecycle interfaces, the mutation - system, gating, editors, selectors, Server-Side Apply, cluster-scoped and unstructured primitives. +- `references/primitives.md`: concepts shared across every primitive, including categories, lifecycle interfaces, the + mutation system, gating, editors, selectors, Server-Side Apply, and cluster-scoped and unstructured primitives. - `references/primitives/.md`: per-kind builders, mutations, editors, and suspension behavior. Read the specific kind's file before writing a mutation against it. From 5c2a3d5d2856fe6d1f2d5b1a14183e9a947abc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:35 +0200 Subject: [PATCH 07/21] feat(plugin): custom-resource-wrappers skill Co-Authored-By: Claude Fable 5 --- .../skills/custom-resource-wrappers/SKILL.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 plugin/skills/custom-resource-wrappers/SKILL.md diff --git a/plugin/skills/custom-resource-wrappers/SKILL.md b/plugin/skills/custom-resource-wrappers/SKILL.md new file mode 100644 index 00000000..fae8eebc --- /dev/null +++ b/plugin/skills/custom-resource-wrappers/SKILL.md @@ -0,0 +1,265 @@ +--- +name: custom-resource-wrappers +description: + Use when wrapping a custom resource (a CRD-backed type not covered by the built-in primitives) as an + operator-component-framework primitive using pkg/generic - covers choosing a resource category, mutation type aliases, + implementing the mutator, status handlers, the builder, the resource type, feature mutations, and component + registration. +--- + +# Custom Resource Wrappers + +## When to write a wrapper + +The built-in primitives cover the common Kubernetes kinds (Deployments, StatefulSets, ConfigMaps, Services, and more). +Reach for a custom resource wrapper only when the kind an operator manages has no matching primitive: a custom CRD +defined by the project or a third-party operator, or a standard Kubernetes kind the built-in set does not yet wrap. + +`pkg/generic` supplies the building blocks (reconciliation mechanics, the plan-and-apply mutation flow, suspension, +guards, data extraction). A wrapper package combines these with kind-specific identity, status, and mutator logic, the +same way the built-in primitives do. + +If the CRD has no typed Go struct, an unstructured primitive +(`pkg/primitives/unstructured/{static,workload,integration,task}`) manages it without writing a wrapper at all. That is +the lightweight alternative: it is sufficient when a typed, self-documenting API is not needed for a kind the operator +touches only occasionally. Write a full wrapper when a typed struct exists and the kind is managed often enough to +justify a dedicated package. + +A custom resource is three wrapped pieces: the builder configures and validates, producing a resource; the resource +delegates lifecycle methods to a generic base; the mutator records and applies changes to the Kubernetes object. + +| Your type | Wraps | +| ---------- | ------------------------------------------------------------- | +| `Builder` | `generic.WorkloadBuilder[T, *Mutator]` (or one per category) | +| `Resource` | `generic.WorkloadResource[T, *Mutator]` (or one per category) | +| `Mutator` | Implements `generic.FeatureMutator` | + +## The eight steps + +### 1. Choose a resource category + +The framework defines four categories, each mapping to a generic resource type with a different set of lifecycle +interfaces: + +| Category | Generic type | Lifecycle interfaces | Use when | +| --------------- | ----------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------ | +| **Workload** | `generic.WorkloadResource` | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | Long-running processes with replica-based health | +| **Static** | `generic.StaticResource` | `Guardable`, `DataExtractable` | Configuration objects with no runtime health semantics | +| **Task** | `generic.TaskResource` | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | Run-to-completion workloads | +| **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | + +Every generic resource also satisfies `concepts.Previewable` and `concepts.MutationInspector`, regardless of category. +The category choice determines which status handlers are required or meaningful (see Choosing a category below) and +which methods the resource wrapper needs to implement. + +### 2. Define the mutation type alias + +Create a type alias for `feature.Mutation` parameterized on the mutator, mirroring the alias each built-in primitive +exports: + +```go +type Mutation = feature.Mutation[*Mutator] +``` + +This gives callers a clean name when defining feature mutations for the wrapped kind. + +### 3. Implement the mutator + +The mutator records mutation intent and applies it in a single controlled pass. It must implement +`generic.FeatureMutator`: + +```go +type FeatureMutator interface { + Apply() error + NextFeature() +} +``` + +`Apply()` executes all recorded mutations against the underlying object; `NextFeature()` advances to a new feature +scope, called by the framework between each registered mutation to maintain per-feature ordering boundaries. Mutator +methods record intent rather than modifying the object directly, the same plan-and-apply model the built-in primitives +use. The key decision here is keeping the exposed methods domain-specific (`SetMaxConnections`, `SetReplicas`) rather +than generic, so feature mutations stay self-documenting: + +```go +type Mutator struct { + current *examplev1.MessageQueue + plans []featurePlan + active *featurePlan +} + +func NewMutator(current *examplev1.MessageQueue) *Mutator { + m := &Mutator{current: current} + m.NextFeature() + return m +} + +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +func (m *Mutator) SetReplicas(replicas int32) { + m.active.replicaOps = append(m.active.replicaOps, func(spec *examplev1.MessageQueueSpec) { + spec.Replicas = &replicas + }) +} + +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + for _, op := range plan.replicaOps { + op(&m.current.Spec) + } + } + return nil +} +``` + +### 4. Implement status handlers + +Status handlers translate the CRD's runtime state into framework status types. Which handlers are needed depends on +category (see Choosing a category below). The generic builder's `Build()` fails if the convergence handler is missing: +for workload and task resources this is the handler registered with `WithCustomConvergeStatus`; for integration +resources it is `WithCustomOperationalStatus`. Every other handler defaults to a safe value at the generic layer: grace +status defaults to `Healthy` (workload and integration only), suspension status defaults to `Suspended`, the suspension +mutation defaults to a no-op, and the delete-on-suspend decision defaults to `false`. Register custom handlers only +where the CRD has domain-specific behavior. + +The convergence handler and the grace handler evaluate the same object in the same reconcile loop, with no refetch +between them. When convergence returns `Healthy`, grace is never called; for every other state, grace must not +contradict convergence by also returning `Healthy`. The component logs a warning when it detects this inconsistency; if +intentional, pass `component.SuppressGraceInconsistencyWarning()` to `WithResource` to silence it. + +### 5. Implement the builder + +The builder wraps the generic builder (`generic.NewWorkloadBuilder`, `generic.NewStaticBuilder`, +`generic.NewTaskBuilder`, or `generic.NewIntegrationBuilder`), registers default handlers in its constructor, and +exposes a fluent configuration API. The identity function is required and must produce a stable, unique identity: the +framework's convention is `///`. Every method should return `*Builder` for +chaining, and `Build()` validates before delegating to the generic build, which checks a non-nil object, a name, a +namespace (unless cluster-scoped), the identity function, the mutator factory, the required convergence handler, and +unique mutation names. + +```go +type Builder struct { + base *generic.WorkloadBuilder[*examplev1.MessageQueue, *Mutator] +} + +func NewBuilder(mq *examplev1.MessageQueue) *Builder { + identityFunc := func(mq *examplev1.MessageQueue) string { + return fmt.Sprintf("messagequeues.example.io/v1/MessageQueue/%s/%s", mq.Namespace, mq.Name) + } + + base := generic.NewWorkloadBuilder[*examplev1.MessageQueue, *Mutator](mq, identityFunc, NewMutator) + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler) + + return &Builder{base: base} +} + +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} +``` + +`generic.WrapGuard` and `generic.WrapExtractor` convert value-receiver callbacks (`func(T)`) into the pointer-receiver +form the generic layer expects, so the wrapper's public API can take the kind by value. + +### 6. Implement the resource + +The resource is a thin wrapper that delegates every interface method to the generic base. This layer exists so the +package exports a concrete type rather than a generic one; list the interfaces it satisfies in its GoDoc. Do not omit +`Preview()`: it satisfies `concepts.Previewable`, and without it `component.Preview()` fails at runtime and golden +snapshot tests cannot render the resource. `RegisteredMutations()` and `FiringSet()` satisfy +`concepts.MutationInspector` and are used by version-matrix golden generation to introspect which mutations a resource +registers and which fire at a given version; delegate both to the base. Forward `RecordObservation` whenever the +resource may be registered read-only with a data extractor, since the framework feeds the fetched cluster object back to +the resource before extraction runs. + +Which methods to include depends on category: a Static resource needs only `Identity`, `Object`, `Mutate`, +`GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, and `FiringSet`. Workload, Task, +and Integration resources add `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, and `SuspensionStatus`; Workload and +Integration additionally add `GraceStatus`. For Task and Integration resources, `ConvergingStatus` returns +`concepts.CompletionStatusWithReason` and `concepts.OperationalStatusWithReason` respectively, matching the generic base +method signature. + +### 7. Define feature mutations + +Feature mutations use the `Mutation` alias from step 2. Each declares a name, an optional feature gate, and a function +that calls mutator methods to record intent. Name every mutation: the name is what gating and error reporting refer to, +and the builder rejects duplicate names within a resource. Mutations apply in registration order; when a mutation's +`Feature` is nil or its gate reports enabled, its `Mutate` function runs, otherwise it is skipped. Version gating uses +`feature.NewVersionGate`, boolean conditions combine with `.When(...)`. + +### 8. Register with a component + +Use the custom resource with the component builder exactly like a built-in primitive: build it with the wrapper's +`NewBuilder`, register feature mutations with `WithMutation`, call `Build()`, then pass the result to +`component.NewComponentBuilder().WithResource(...)`. Resource options such as `ReadOnly()`, `Auxiliary()`, and +`BlockOnAbsence()` apply the same way they do to built-in primitives. + +## Choosing a category + +The category choice determines which status handlers are required, which are meaningful, and which methods the resource +wrapper implements (step 6 above): + +- **Static** resources have the simplest implementation. They do not participate in convergence, grace, or suspension + reporting; the builder uses `generic.NewStaticBuilder`. `pkg/primitives/configmap` is a complete reference. +- **Task** resources use `generic.NewTaskBuilder` and report convergence as `concepts.CompletionStatusWithReason` + instead of `AliveStatusWithReason`. The converging handler, registered with `WithCustomConvergeStatus`, reports + `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing`. +- **Integration** resources use `generic.NewIntegrationBuilder` and report convergence as + `concepts.OperationalStatusWithReason`. The handler is registered with `WithCustomOperationalStatus`, not + `WithCustomConvergeStatus`, and reports `Operational`, `OperationPending`, or `OperationFailing`. Integration + resources also implement `Graceful`, defaulting to `Healthy`, so the resource wrapper includes `GraceStatus` alongside + the other methods. `pkg/primitives/service` is a complete reference, including a grace handler that mirrors the + operational logic. +- **Workload** resources implement the full set: `Alive`, `Graceful`, `Suspendable`, `Guardable`, and `DataExtractable`, + with convergence reported as `AliveStatusWithReason`. + +## Cluster-scoped wrappers + +For cluster-scoped CRDs, call `MarkClusterScoped()` on the generic builder before building. Validation then rejects a +non-empty namespace instead of requiring one, and the identity function should omit the namespace segment. + +## Anti-patterns + +- **Skipping status handlers.** The component can never report readiness if the required convergence handler + (`WithCustomConvergeStatus` or, for Integration, `WithCustomOperationalStatus`) is missing: `Build()` fails outright. + Register it even for a minimal implementation. +- **Embedding owner-specific logic in the wrapper instead of feature mutations.** Version-dependent or feature-flag + dependent behavior belongs in a named, gated `Mutation` (step 7), not hardcoded into the mutator or builder defaults. + Hardcoding it defeats gating, golden testing, and per-mutation introspection. +- **Wrapping a kind that already has a built-in primitive.** Check the built-in primitive list before writing a wrapper; + duplicating an existing primitive's behavior in a custom wrapper creates two divergent implementations of the same + kind. + +## Ground truth + +The consumer's resolved module version is the source of truth, not these docs. Before asserting an exact signature, +method name, or option: + +1. Read the framework version from the consumer's `go.mod` entry for + `github.com/sourcehawk/operator-component-framework`. +2. Verify the symbol with `go doc github.com/sourcehawk/operator-component-framework/pkg/ `. + +The reference files bundled with this skill match the framework version this plugin shipped with. When they disagree +with `go doc`, `go doc` wins. + +## References + +- `references/custom-resource.md`: the complete worked example (a `MessageQueue` workload CRD, plus a `DNSRecord` + integration example), including the full mutator, builder, resource, and feature mutation listings, the + status-constant reference table, and the cluster-scoped and category-specific sections referenced above. From c7e310f190511afb4b78bfffdd83ca2a59c54a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:13:48 +0200 Subject: [PATCH 08/21] fix(plugin): scope unstructured primitive claim to the wrapper reference Co-Authored-By: Claude Fable 5 --- plugin/skills/custom-resource-wrappers/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin/skills/custom-resource-wrappers/SKILL.md b/plugin/skills/custom-resource-wrappers/SKILL.md index fae8eebc..06d34cea 100644 --- a/plugin/skills/custom-resource-wrappers/SKILL.md +++ b/plugin/skills/custom-resource-wrappers/SKILL.md @@ -19,11 +19,11 @@ defined by the project or a third-party operator, or a standard Kubernetes kind guards, data extraction). A wrapper package combines these with kind-specific identity, status, and mutator logic, the same way the built-in primitives do. -If the CRD has no typed Go struct, an unstructured primitive -(`pkg/primitives/unstructured/{static,workload,integration,task}`) manages it without writing a wrapper at all. That is -the lightweight alternative: it is sufficient when a typed, self-documenting API is not needed for a kind the operator -touches only occasionally. Write a full wrapper when a typed struct exists and the kind is managed often enough to -justify a dedicated package. +If the CRD has no typed Go struct, the unstructured static primitive (`pkg/primitives/unstructured/static`) manages it +without writing a wrapper at all. That is the lightweight alternative: it is sufficient when a typed, self-documenting +API is not needed for a kind the operator touches only occasionally. Write a full wrapper when a typed struct exists and +the kind is managed often enough to justify a dedicated package. Other unstructured variants exist per resource +category; see the using-primitives skill for the full set. A custom resource is three wrapped pieces: the builder configures and validates, producing a resource; the resource delegates lifecycle methods to a generic base; the mutator records and applies changes to the Kubernetes object. From 14d05799e2e3e3c999fd71c90a6e6c33dabb61af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:19:53 +0200 Subject: [PATCH 09/21] feat(plugin): structuring-operators skill Co-Authored-By: Claude Fable 5 --- plugin/skills/structuring-operators/SKILL.md | 191 +++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 plugin/skills/structuring-operators/SKILL.md diff --git a/plugin/skills/structuring-operators/SKILL.md b/plugin/skills/structuring-operators/SKILL.md new file mode 100644 index 00000000..998e5ff9 --- /dev/null +++ b/plugin/skills/structuring-operators/SKILL.md @@ -0,0 +1,191 @@ +--- +name: structuring-operators +description: + Use when designing, structuring, or reviewing an operator built on the operator-component-framework - desired state in + the baseline, pure mutations, one component per logical condition, thin controllers, mutation ordering and layering, + prerequisites vs guards vs feature gates, participation modes, grace periods, naming conventions, version floors, and + supported version pinning. +--- + +# Structuring Operators + +## Three load-bearing principles + +**Desired state lives in the baseline object.** The object passed to a primitive builder should already read as the +real, latest-shape resource: name, labels, selector, replicas, ports, probes, and the primary container all belong +there. Mutations layer conditional or version-dependent concerns on top of a complete, valid baseline, not the other way +around. + +**Mutations are pure functions of the spec.** A mutation computes its output from the owner spec and other build-time +inputs only. It never reads a resource's live cluster state to decide what to write, and within a single resource it +runs before that resource's own data extractors, so a mutation cannot see data its own resource has not yet produced. + +**One component per logical condition.** If users would ask "is the backend ready?" and "is the frontend ready?" as +separate questions, those are separate components, each reporting its own condition. Combine resources into one +component only when they have no useful readiness independent of each other. + +## The guideline index + +Every guideline from `references/guidelines.md`, verbatim, with the rule in one sentence. Use this table as a review +checklist: a change that violates one of these rules is a candidate for rework, not just a style nit. + +| Guideline | Rule | +| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Represent Desired State in the Baseline Object | Put every field that is always present, regardless of version or feature flags, in the baseline; leave only conditional fields to mutations. | +| Mutations Are Pure Functions of the Spec | A mutation is a pure function of the owner spec and build-time inputs; it must never read live cluster state. | +| Leave Version-Dependent Fields Empty in the Baseline | Give each field exactly one owner; when a value depends on the spec version, leave it empty in the baseline and let a single mutation set it. | +| One Component Per Logical Condition | Split components when users would ask about their health separately or a failure in one should not mask another; combine when resources have no independent readiness. | +| Keep Controllers Thin | A controller fetches the owner, builds and reconciles components, and defers one `FlushStatus`; resource construction and mutation logic live in pure, testable component-building functions. | +| Reconciler Error Handling and Requeueing | Return an error only for a genuine fault (a failed API call, a mutation that cannot apply, a version below the supported floor); let a merely converging resource report through its condition and requeue via normal watch and resync. | +| Resource Registration Order Is Execution Order | Resources reconcile in the exact order they were registered with `WithResource`; register dependencies before dependents. | +| Mutation Ordering and Container-Name Dependencies | Use broad, name-independent selectors for version-independent mutations, and register name-specific mutations before any compat mutation that renames the container. | +| Layer Mutations in a Fixed Order | Order a resource's mutations into fixed layers: defaults, compat, overrides, then checksum, so the pipeline reads the same way for every workload. | +| Prefer Reverting Compat Mutations Over Forward Mutations | Keep the baseline at the latest shape and add a version-gated revert mutation per structural change, rather than holding the baseline at an old shape and patching it forward. | +| Use Data Extraction and Guards for Intra-Component Dependencies | When one resource depends on data from another resource in the same component, register a data extractor on the source and a guard on the dependent. | +| Use Prerequisites for Cross-Component Dependencies | When a component cannot start until another component is ready, attach a prerequisite instead of orchestrating ordering in the controller. | +| Use Feature Gates for Optional Components and Conditional Resources | Gate optional pieces with a feature gate so the framework owns the full lifecycle, including deletion when the gate flips off. | +| Provide a User-Override Escape Hatch as the Last Mutation | Apply a documented user-override mutation as the last value-producing mutation so the user's input shadows the operator's own defaults. | +| Fail Loudly Below the Supported Version Floor | Return an error from a compat mutation, rather than emit a silently wrong approximation, when a requested version is below the supported floor. | +| Name Mutations for Golden Introspection | Give every mutation a descriptive `Name`; golden manifests reference those names in their `requires` and `forbids` lists. | +| Understand Participation Modes | `Auxiliary` means reconciled but not required for the condition to go Ready, not skipped; a blocked guard always contributes to the condition regardless of participation mode. | +| Grace Periods Are Convergence Time | Set the grace period to how long a resource legitimately takes to converge, not as a general safety margin. | +| Handle Cluster-Scoped Resources Explicitly | Clean up cluster-scoped resources explicitly with `Delete()` or `DeleteWhen()` plus a finalizer, since the framework cannot set an owner reference across a scope boundary. | +| Name Resources to Avoid Multi-Tenant Collisions | Derive every managed resource's name from the owner, and fold in the owner's namespace for cluster-scoped resources, which share one global namespace. | +| Name Conditions for the Audience Reading Them | Name condition types after the capability they represent (`BackendReady`), not the Kubernetes resource type backing them (`StatefulSetHealthy`). | +| Pin Rendered Output Across Supported Versions | Cover every supported version's rendered output with a golden, so a baseline change can be proven to touch only the version intended. | + +## Choosing the right dependency mechanism + +Three mechanisms cover three different dependency shapes. Picking the wrong one either breaks silently or forces +orchestration logic back into the controller. + +**Data extraction and guards, for a dependency between two resources inside one component.** Register a data extractor +on the source resource and a guard on the dependent resource. Do not assume a resource is ready just because it was +registered earlier; the guard is what actually enforces the wait. + +```go +var roleARN string + +roleRes, _ := static.NewBuilder(cloudRole(app)). + WithDataExtractor(func(obj uns.Unstructured) error { + roleARN, _, _ = unstructured.NestedString(obj.Object, "status", "arn") + return nil + }). + Build() + +bucketRes, _ := static.NewBuilder(cloudBucket(app)). + WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { + if roleARN == "" { + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusBlocked, + Reason: "waiting for cloud role ARN", + }, nil + } + return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil + }). + Build() +``` + +The guard re-evaluates every reconcile, so it naturally re-blocks the dependent if its input disappears. Prefer stable +values (a status field written once, a generated credential reference) over values that can transiently clear during +normal operation (a replica count, a field cleared mid rolling-update), or the guard will re-block a resource that is +already running. + +**Prerequisites, for a dependency between two components.** Attach `WithPrerequisite` on the dependent component rather +than sequencing the components in the controller. + +```go +frontendComp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithPrerequisite(component.DependsOn("BackendReady")). + WithResource(frontendService). + WithResource(frontendDeployment). + Build() +``` + +A prerequisite is a startup barrier only: once a component passes it for the first time, the barrier is permanently +satisfied and never re-checked, even if the depended-on component later becomes unhealthy. Use this for "can this +component be created?", not for ongoing health coupling; if the backend later goes down, the frontend keeps reconciling +and the two conditions reflect their own health independently. + +**Feature gates, for an optional component or an optional resource within a component.** A component-level +`WithFeatureGate` disables the whole component, deleting its resources and reporting `True/Disabled`. + +```go +cacheComp, err := component.NewComponentBuilder(). + WithName("cache"). + WithConditionType("CacheReady"). + WithFeatureGate(feature.NewVersionGate(app.Spec.Version, nil).When(app.Spec.Cache.Enabled)). + WithResource(cacheService). + WithResource(cacheDeployment). + Build() +``` + +A resource-level `component.GatedBy` does the same for one resource the component owns, deleting it once the gate turns +off. For an optional resource the component does not own, such as a read-only Secret reference behind an optional spec +field, use `IncludeWhen` instead, which omits the resource without ever deleting it. + +The three mechanisms compose: a component can have a feature gate, a prerequisite, and internal guards simultaneously, +each answering a different question (is this component enabled, can it start, is this resource's own dependency +satisfied right now). + +## Compatibility + +The framework requires Go 1.25 or later, and its own supported dependency combinations are documented in +`references/compatibility.md`: + +| Framework | controller-runtime | k8s.io/\* | Kubernetes | Go | Status | +| --------- | ------------------ | --------- | ---------- | ---- | ------- | +| main | v0.23.x | v0.35.x | 1.35 | 1.25 | Primary | +| main | v0.22.x | v0.34.x | 1.34 | 1.25 | Tested | + +**Primary** is tested on every commit and is what `go.mod` declares. **Tested** combinations are verified weekly and are +fully supported: bugs reported against a Tested combination are treated as bugs in the framework, not as unsupported +configurations. Versions v0.21.x and below are not supported at all, because dependency module path migrations in that +range make those combinations irresolvable. When you need to stay on an older Tested combination, pin your own +controller-runtime and `k8s.io/*` versions with `replace` directives in your `go.mod`; Go's Minimum Version Selection +otherwise pulls your dependencies up to the framework's declared minimums. + +The same "fail below the floor, do not degrade quietly" policy applies to an operator's own owner-CRD version field. Per +the Fail Loudly Below the Supported Version Floor guideline, when a compat mutation cannot faithfully represent a +requested version, it should return an error rather than render an approximation: + +```go +func compatV1Container(app *v1alpha1.WebApp) deployment.Mutation { + return deployment.Mutation{ + Name: "CompatV1Container", + Feature: feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}), + Mutate: func(m *deployment.Mutator) error { + if belowFloor(app.Spec.Version, "1.0.0") { + return fmt.Errorf("version %s is below the supported floor 1.0.0", app.Spec.Version) + } + // ... roll back to the legacy shape + return nil + }, + } +} +``` + +That error propagates out of `Component.Reconcile`, and because `FlushStatus` is deferred, the failure still lands on +the owner's condition even as the error causes controller-runtime to back off and retry. Do not attempt a best-effort +render for an unsupported version: a loud, visible failure is the correct behavior, not a bug to be worked around. + +## Ground truth + +The consumer's resolved module version is the source of truth, not these docs. Before asserting an exact signature, +method name, or option: + +1. Read the framework version from the consumer's `go.mod` entry for + `github.com/sourcehawk/operator-component-framework`. +2. Verify the symbol with `go doc github.com/sourcehawk/operator-component-framework/pkg/ `. + +The reference files bundled with this skill match the framework version this plugin shipped with. When they disagree +with `go doc`, `go doc` wins. + +## References + +- `references/guidelines.md`: full guideline text, rationale, and code examples for every row in the guideline index + above. +- `references/compatibility.md`: the framework's supported version matrix, the Go requirement, the version floor policy, + and how to pin your own controller-runtime and Kubernetes versions. From 778f6e17117d0f0a2ef8d312d1fde708dac32292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:25:08 +0200 Subject: [PATCH 10/21] feat(plugin): testing-operators skill Co-Authored-By: Claude Fable 5 --- plugin/skills/testing-operators/SKILL.md | 214 +++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 plugin/skills/testing-operators/SKILL.md diff --git a/plugin/skills/testing-operators/SKILL.md b/plugin/skills/testing-operators/SKILL.md new file mode 100644 index 00000000..09bec59d --- /dev/null +++ b/plugin/skills/testing-operators/SKILL.md @@ -0,0 +1,214 @@ +--- +name: testing-operators +description: + Use when writing or updating tests for an operator built on the operator-component-framework - the three test layers, + mutation unit tests, golden snapshot tests (pkg/testing/golden), version-matrix golden generation + (pkg/testing/goldengen), the YAML matrix loader, and integration helpers (pkg/testing/integration). +--- + +# Testing Operators + +## The three layers + +Test a component from the inside out. Each layer asserts something the layer below cannot: + +| Layer | What you assert | Tool | +| ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------- | +| **Mutation** | one mutation makes the field changes you intend, on a baseline | testify, against `Preview()` | +| **Resource** | the right mutations fire for a spec, and the rendered output is pinned | `golden` for a snapshot, `goldengen.Resource` for coverage | +| **Component** | the whole component renders the resources you expect, applied together | `golden.AssertComponentYAML`, or `goldengen.Component` | + +Two framework packages back this: `pkg/testing/golden` for single-build snapshot tests, and `pkg/testing/goldengen` for +declarative coverage across versions and specs. Both are opt-in and import nothing into the reconcile path, so a +consumer that does not test against them pays nothing. + +## Mutation tests + +A mutation is a pure function: given a baseline object, it makes a specific, isolated set of field changes. Test it as +an input/output pair, not against a golden file. Build a minimal baseline primitive with only the mutation under test, +preview it, and assert the fields it changed: + +```go +func TestDebugLoggingMutation(t *testing.T) { + res, err := deployment.NewBuilder(baseDeployment()). + WithMutation(features.DebugLoggingMutation(true)). + Build() + require.NoError(t, err) + + dep, err := res.Preview() + require.NoError(t, err) + + container := dep.(*appsv1.Deployment).Spec.Template.Spec.Containers[0] + assert.Contains(t, container.Env, corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) +} +``` + +There is no golden file at this layer: the assertion states intent directly, against the specific fields the mutation is +documented to change. Share a minimal `baseDeployment()` / `baseConfigMap()` baseline across a package's mutation tests +in a `helpers_test.go` so each test declares only what it exercises. + +## Golden snapshots + +`golden` renders a built primitive or component to canonical YAML and compares it against a checked-in file. What gets +snapshotted is the full rendered desired state, not the mutation list or internal builder state: serialization resolves +`TypeMeta` (from the object or a supplied scheme) and strips zero-value noise, so the golden reflects only meaningful +desired state. + +Typed Kubernetes objects (built-in primitives and standard `k8s.io/api` types) do not populate `TypeMeta` on their own. +Serializing one without a scheme fails with an incomplete-`TypeMeta` error, so pass `golden.WithScheme(scheme)` to every +`AssertYAML` and `AssertComponentYAML` call; the scheme only needs to register the types being serialized. + +`AssertYAML` accepts a `golden.Previewer` (`Preview() (client.Object, error)`); `AssertComponentYAML` accepts a +`golden.ComponentPreviewer` (`Preview() ([]client.Object, error)`). All built-in primitives satisfy `Previewer` through +`generic.BaseResource`, and a built `*component.Component` satisfies `ComponentPreviewer` directly. + +```go +var update = flag.Bool("update", false, "update golden files") + +func TestDeploymentGolden(t *testing.T) { + res, err := resources.NewDeploymentResource(owner) + require.NoError(t, err) + + previewer, ok := res.(golden.Previewer) + require.True(t, ok) + golden.AssertYAML(t, "testdata/deployment.yaml", previewer, + golden.WithScheme(scheme), golden.Update(*update)) +} +``` + +### Regeneration + +`golden.Update(*update)` overwrites the golden file instead of comparing. Generate once, inspect the diff, then commit: + +```bash +go test ./path/to/pkg -run TestDeploymentGolden -update +go test ./path/to/pkg -run TestDeploymentGolden +``` + +The `-update` flag goes after the package path: `go test -update ./...` passes `-update` to `go test` itself, which +rejects it. Golden files live in a `testdata/` directory next to the test file (Go excludes `testdata/` from the build). + +Non-`testing.T` variants (`CompareYAML`, `CompareComponentYAML`) return a `*MismatchError` carrying a unified diff +instead of failing a test, for use outside a test body. `golden.Serialize` and `golden.SerializeComponent` produce the +canonical YAML bytes directly when you need them out of band; `goldengen` is built on exactly these two functions. + +### Why mutation names matter + +`goldengen` (and golden introspection generally) classifies which mutations fired by reading a resource's +`RegisteredMutations()` and `FiringSet()`, the `concepts.MutationInspector` interface every built resource and component +implements. Those sets are keyed by mutation name, and the name is what `Requires`/`Forbids` assertions, `Exclude` +entries, and the completeness check all refer to. A mutation with an empty name is itself a completeness violation. Give +every mutation a stable, descriptive name (for example `PeerDiscovery/V2`): it is the identifier the whole coverage and +gating story is built on, and it is what shows up in the generated manifest when a reviewer reads a gating diff. + +## goldengen version matrices + +A resource with version-gated mutations behaves differently across versions, but only where a gate actually flips. +`goldengen` sweeps a declared set of versions and specs, groups the versions by which mutations fire (a "regime"), and +writes one golden per distinct regime instead of one golden per version, then proves through `AssertComplete` that every +registered mutation was asserted somewhere. + +A matrix is warranted once a resource or component has version-gated mutations whose behavior needs to be pinned across +more than one version: asserting one golden per version at that point is wasteful (versions inside the same regime +produce identical output) and, more importantly, does not prove where the behavior boundary actually is. A single golden +snapshot is enough when there is nothing version-gated to sweep, or you only care about one fixed version's output; +reach for a matrix specifically to lock down a gate boundary, and pin both sides of it (`Requires` at the version after +the boundary, `Forbids` at the version before) so the boundary itself is asserted, not just "fires somewhere". + +Declare the sweep with `goldengen.Config[T]` (`Dir`, `Versions`, `Fixtures`, `Exclude`, `Build`), where `Build` adapts a +version-and-spec pair into a `goldengen.Unit` via `goldengen.Resource(res, scheme)` or +`goldengen.Component(comp, scheme)`: + +```go +var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ + Dir: "testdata/version_matrix", + Versions: []string{"1.0.0", "1.5.0", "2.0.0"}, + Fixtures: []goldengen.Fixture[*app.ExampleApp]{{ + Name: "default", + Spec: defaultCluster(), + Requires: []goldengen.Expect{ + {Name: "ContainerImage"}, + {Name: "PeerDiscovery/PreV2", For: "1.5.0"}, + {Name: "PeerDiscovery/V2", For: "2.0.0"}, + }, + Forbids: []goldengen.Expect{ + {Name: "PeerDiscovery/V2", For: "1.5.0"}, + }, + }}, + Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) { + c := spec.DeepCopyObject().(*app.ExampleApp) + c.Spec.Version = version + res, err := resources.NewStatefulSetResource(c) + if err != nil { + return nil, err + } + return goldengen.Resource(res, scheme), nil + }, +}) +``` + +`Build` must deep-copy the incoming spec before setting the version: it is called once per version for the same fixture, +and the spec is shared across that sweep. List `Versions` ascending; the representative golden for a regime is named +after the first version (in supplied order) that belongs to it, so ascending order puts each golden's filename on the +lower inclusive boundary of its gating range. + +Wire a sweep into a normal test: + +```go +func TestVersionMatrix(t *testing.T) { + gen.WithUpdate(*update) + gen.Run(t) +} + +func TestMain(m *testing.M) { + os.Exit(gen.AssertComplete(m.Run())) +} +``` + +`Run` validates the config, builds every fixture at every version, checks each `Requires`/`Forbids` during the sweep, +then writes (under `-update`) or compares one golden per regime plus a reviewable `manifest.yaml` (per fixture, each +regime's representative version, the versions it covers, and its firing set). `AssertComplete`, called from `TestMain`, +is a separate, registration-based check: it fails when a registered mutation is named in neither `Requires` nor +`Exclude`, or when a `Requires`/`Exclude` name matches nothing registered. Registering a new version-gated mutation +therefore fails the suite until it is asserted or deliberately excluded. + +## YAML matrix loader + +`goldengen.LoadMatrix[T](path, newSpec, build)` loads a matrix's `Dir`, `Versions`, `Fixtures` (with their `Requires` / +`Forbids` / `Exclude`) from a YAML file instead of Go source, keeping the version universe and fixture data separate +from the build logic, which still lives in the `build` callback passed to `LoadMatrix`. Each fixture supplies its spec +either inline under `spec:` or from an external file under `specFile:`, exactly one of the two. Reach for the YAML +loader when the matrix data (versions, fixtures, gating expectations) is more naturally maintained as data files than as +a Go literal, for example when non-Go-writing maintainers curate fixtures, or the same matrix shape is reused across +several resources with only the data changing. `LoadMatrix` returns a validated `Config[T]`; wrap it with +`goldengen.New(cfg)` and call `Run` exactly as with a Go-declared config. It errors if a fixture sets both `spec` and +`specFile` (or neither), if a `for` value is not in `versions`, or if a spec fails to unmarshal into `T`. + +## Anti-patterns + +- **Regenerating goldens to make a failing test pass without reading the diff.** A golden mismatch means the rendered + output changed; run with `-update` only after confirming the new output is the output you intend, not as a reflex to + clear a red test. +- **Asserting on implementation internals instead of rendered output.** Golden and goldengen tests exist to pin what + gets applied to the cluster; asserting on builder state or mutation call counts instead of the previewed object misses + the thing that actually matters to a consumer. +- **Skipping the version matrix for version-gated mutations.** A single golden at one version cannot prove where a gate + boundary sits; a version-gated mutation without a matrix (or without `Requires`/`Forbids` pinning both sides of its + boundary) is unverified at the versions that matter most. + +## Ground truth + +The consumer's resolved module version is the source of truth, not these docs. Before asserting an exact signature, +method name, or option: + +1. Read the framework version from the consumer's `go.mod` entry for + `github.com/sourcehawk/operator-component-framework`. +2. Verify the symbol with `go doc github.com/sourcehawk/operator-component-framework/pkg/ `. + +The reference files bundled with this skill match the framework version this plugin shipped with. When they disagree +with `go doc`, `go doc` wins. + +## References + +- `references/testing.md`: full testing documentation. Read when you need exact `goldengen` config field semantics, the + completeness accounting rules, or the YAML matrix file format. From 24732631aabd211775f77bfaaf3697ce516ec769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:28:55 +0200 Subject: [PATCH 11/21] fix(plugin): drop unbacked integration-helpers claim from testing-operators description Co-Authored-By: Claude Fable 5 --- plugin/skills/testing-operators/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/skills/testing-operators/SKILL.md b/plugin/skills/testing-operators/SKILL.md index 09bec59d..c657bf11 100644 --- a/plugin/skills/testing-operators/SKILL.md +++ b/plugin/skills/testing-operators/SKILL.md @@ -3,7 +3,7 @@ name: testing-operators description: Use when writing or updating tests for an operator built on the operator-component-framework - the three test layers, mutation unit tests, golden snapshot tests (pkg/testing/golden), version-matrix golden generation - (pkg/testing/goldengen), the YAML matrix loader, and integration helpers (pkg/testing/integration). + (pkg/testing/goldengen), and the YAML matrix loader. --- # Testing Operators From bc1b2877f9074ba64576cdd1df3fb3e689604e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:31:46 +0200 Subject: [PATCH 12/21] feat(plugin): /ocf:docs documentation lookup command Co-Authored-By: Claude Fable 5 --- plugin/commands/docs.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 plugin/commands/docs.md diff --git a/plugin/commands/docs.md b/plugin/commands/docs.md new file mode 100644 index 00000000..ffbdaf8c --- /dev/null +++ b/plugin/commands/docs.md @@ -0,0 +1,25 @@ +--- +description: Look up operator-component-framework documentation for a topic +argument-hint: +--- + +Look up operator-component-framework documentation for: $ARGUMENTS + +Resolution order: + +1. Search the bundled references first. They live under `${CLAUDE_PLUGIN_ROOT}/skills/*/references/`: + - `building-components/references/component.md`: component builder, lifecycle, status model, guards + - `using-primitives/references/primitives.md`: primitive concepts, mutation system, editors, selectors + - `using-primitives/references/primitives/.md`: per-kind primitive builders and mutators + - `custom-resource-wrappers/references/custom-resource.md`: wrapping CRD-backed types with pkg/generic + - `structuring-operators/references/guidelines.md`: operator structuring best practices + - `structuring-operators/references/compatibility.md`: supported version policy + - `testing-operators/references/testing.md`: mutation tests, golden snapshots, goldengen +2. If the topic is not covered there, fetch the published documentation at + https://sourcehawk.github.io/operator-component-framework/ +3. For exact signatures, verify against the version the consumer actually uses: read `go.mod` for the + `github.com/sourcehawk/operator-component-framework` version, then run + `go doc github.com/sourcehawk/operator-component-framework/pkg/ `. + +Answer the question directly, citing which reference file or URL each claim came from. If the bundled references and +`go doc` disagree, trust `go doc` and say the plugin docs may lag the consumer's framework version. From 97fed0dac69720f9238d0a515ba6c0114e9ec13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:34:29 +0200 Subject: [PATCH 13/21] feat(plugin): /ocf:new-component scaffolding command Co-Authored-By: Claude Fable 5 --- plugin/commands/new-component.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 plugin/commands/new-component.md diff --git a/plugin/commands/new-component.md b/plugin/commands/new-component.md new file mode 100644 index 00000000..f7646913 --- /dev/null +++ b/plugin/commands/new-component.md @@ -0,0 +1,28 @@ +--- +description: Scaffold a new operator-component-framework component in this operator +argument-hint: [component name] +--- + +Scaffold a new component in this operator project. Component name (may be empty): $ARGUMENTS + +First invoke the `ocf:building-components` skill and follow it. Then: + +1. Confirm this project uses the framework: `go.mod` must require `github.com/sourcehawk/operator-component-framework`. + If it does not, stop and say this command is for operators built on that framework. +2. Study one existing component in this repository (search for the framework's component builder usage) and match its + file layout, naming, and registration wiring. Consistency with the existing operator beats any generic template. +3. Gather what you need before writing code. Ask the user, one question at a time, anything you cannot infer: + - What does the component manage, and what is the logical condition it owns? (One component per logical condition.) + - The condition type name as it should appear on the owner's status. + - Which resource primitives it reconciles, in dependency order (registration order is execution order). + - Participation mode, and whether it is gated behind a feature gate or has prerequisites on other components. +4. Scaffold the component: + - The component constructor with the builder, condition type, and resources registered in dependency order. + - Baseline desired-state functions that hold only version-independent fields; version-dependent or optional fields go + in named mutations. + - Registration in the controller alongside the existing components. +5. Write tests per the `ocf:testing-operators` skill: mutation unit tests for any mutations you added, and a golden + snapshot for the component's rendered output. +6. Verify exact framework signatures with `go doc github.com/sourcehawk/operator-component-framework/pkg/component` + before finalizing; do not invent builder methods. +7. Build and run the project's tests. Report exactly what was created and what the user still needs to fill in. From e9216cc4e6d213f44f01033607bf1f66bdf66e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:36:43 +0200 Subject: [PATCH 14/21] feat(plugin): /ocf:new-wrapper custom resource wrapper command Co-Authored-By: Claude Fable 5 --- plugin/commands/new-wrapper.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 plugin/commands/new-wrapper.md diff --git a/plugin/commands/new-wrapper.md b/plugin/commands/new-wrapper.md new file mode 100644 index 00000000..fe8b1d0d --- /dev/null +++ b/plugin/commands/new-wrapper.md @@ -0,0 +1,26 @@ +--- +description: Scaffold an operator-component-framework primitive wrapper for a custom resource +argument-hint: [kind] +--- + +Scaffold a custom resource wrapper (a framework primitive for a CRD-backed type). Target kind (may be empty): $ARGUMENTS + +First invoke the `ocf:custom-resource-wrappers` skill and follow it. Then: + +1. Confirm this project uses the framework: `go.mod` must require `github.com/sourcehawk/operator-component-framework`. + If it does not, stop and say this command is for operators built on that framework. +2. Check the target kind is not already covered by a built-in primitive (see the `ocf:using-primitives` skill's built-in + list). If it is, use the built-in primitive instead and say so. +3. Ask whether a full wrapper is warranted. If the operator only needs to apply the resource without typed mutations or + status interpretation, the unstructured primitive is the lighter answer; recommend it and stop unless the user + confirms they need typed mutations or status handling. +4. Gather what you need, one question at a time, for anything you cannot infer: + - The Go type and import path of the custom resource. + - The resource category (this decides which status handlers the wrapper implements). + - Namespaced or cluster-scoped. +5. Implement the wrapper following the eight steps in the skill, in order: category, mutation type alias, mutator, + status handlers, builder, resource, feature mutations, component registration. Match the layout of any existing + wrapper in this repository if one exists. +6. Verify exact framework signatures with `go doc github.com/sourcehawk/operator-component-framework/pkg/generic` before + finalizing; do not invent interfaces. +7. Write tests per the `ocf:testing-operators` skill, build, run the project's tests, and report what was created. From 5921538d31577c99bcb19ee500ebd224d847fa74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:41:20 +0200 Subject: [PATCH 15/21] feat(plugin): guidelines reviewer agent and /ocf:review command Co-Authored-By: Claude Fable 5 --- plugin/agents/reviewer.md | 131 ++++++++++++++++++++++++++++++++++++++ plugin/commands/review.md | 10 +++ 2 files changed, 141 insertions(+) create mode 100644 plugin/agents/reviewer.md create mode 100644 plugin/commands/review.md diff --git a/plugin/agents/reviewer.md b/plugin/agents/reviewer.md new file mode 100644 index 00000000..8108306c --- /dev/null +++ b/plugin/agents/reviewer.md @@ -0,0 +1,131 @@ +--- +name: reviewer +description: + Audits an operator codebase against the operator-component-framework guidelines. Use after implementing or changing + components, primitives, mutations, or wrappers in an operator built on the framework. +disallowedTools: Write, Edit +--- + +# Guidelines reviewer + +You review operator code built on `github.com/sourcehawk/operator-component-framework` against the framework's published +guidelines. You audit; you never modify code. Report findings, do not fix them. + +Before auditing, read `${CLAUDE_PLUGIN_ROOT}/skills/structuring-operators/references/guidelines.md` in full. It is the +source of every check below. If you need to verify a framework signature, method name, or option before flagging +something as a violation, run `go doc github.com/sourcehawk/operator-component-framework/pkg/ ` rather +than assuming; do not report a violation grounded in an API shape you have not confirmed. + +## Checklist + +Walk the scope resource by resource, component by component, controller by controller. For each guideline, hunt for the +corresponding violation. A clean pass on a guideline is not itself a finding; only report where the guideline is +actually violated. + +1. **Represent Desired State in the Baseline Object.** Look for a baseline object missing fields that are always present + regardless of version or feature flags (name, namespace, labels, selector, replicas, security context, probes, ports, + primary container), with a mutation filling the gap instead. A baseline that is not a valid, readable resource on its + own, whose validity depends on mutations having already run, is a violation. +2. **Mutations Are Pure Functions of the Spec.** Look for a mutation that reads live cluster state (a client `Get` or + `List` inside `Mutate`, or a closure variable meant to be populated by that same resource's own data extractor) to + decide what to write. Mutations run before data extraction on the same resource, so a mutation depending on its own + resource's extractor output is reading a zero value, not observed state. +3. **Leave Version-Dependent Fields Empty in the Baseline.** Look for a version-dependent field, most commonly the + container image, set directly in the baseline rather than left empty for a single mutation to own. Split ownership + between the baseline and a mutation for the same field is the violation. +4. **One Component Per Logical Condition.** Look for a single component bundling resources whose health users would ask + about separately, so one resource's failure masks another's condition. Also flag the opposite: components split apart + when the resources have no useful readiness independent of each other, adding noise without actionable information. +5. **Keep Controllers Thin.** Look for resource construction, feature decisions, or mutation logic living inline in the + controller rather than in pure component-building functions. Look for `FlushStatus` called more than once per + reconcile, or called between component reconciles rather than deferred once at the end. Look for a controller that + stops reconciling remaining components after the first error instead of collecting the first error and continuing. +6. **Reconciler Error Handling and Requeueing.** Look for `Reconcile` returning an error for a resource that is merely + converging (a rolling Deployment, a `Blocked` guard) instead of letting that state surface through its condition. + Look for an explicit `reconcile.Result{RequeueAfter: ...}` set without a concrete reason to poll on a fixed cadence, + where the normal watch and resync mechanics would already requeue at the right time. +7. **Resource Registration Order Is Execution Order.** Look for `WithResource` calls where a dependent resource is + registered before the resource it depends on, for example a workload registered before the Secret, ServiceAccount, or + Service it needs. Look for a read-only prerequisite resource that omits `BlockOnAbsence` when the rest of the + component should not proceed while it is absent. +8. **Mutation Ordering and Container-Name Dependencies.** Look for a name-specific mutation (targeting `ContainerNamed`) + registered after a compat mutation that renames the container, so the name-specific mutation silently misses its + target. Look for a mutation worked around with `ContainersNamed` matching every historical name instead of either + using a broad selector such as `AllContainers` or being registered before the rename. +9. **Layer Mutations in a Fixed Order.** Look for a resource whose mutations are not ordered as defaults, then compat, + then overrides, then a final checksum annotation mutation, for example overrides applied before compat, or a checksum + mutation that is not last. Look for a version-dependent field guarded by a single version gate rather than a mutually + exclusive pair (`>= V` and `< V`), which risks both layers firing or neither. +10. **Prefer Reverting Compat Mutations Over Forward Mutations.** Look for a baseline held at an old shape with + forward-patching mutations bringing it up to the current shape, rather than a baseline at the latest shape plus a + version-gated revert mutation per structural change. Look for a compat mutation that introduces a new field rather + than only rolling one back. +11. **Use Data Extraction and Guards for Intra-Component Dependencies.** Look for a resource that assumes an + earlier-registered resource in the same component is ready without a `WithGuard` enforcing the wait. Look for a + guard keyed on a value that can transiently disappear (a replica count, a field cleared during a rolling update) + instead of a stable value (a status field written once, a provisioned IP, a generated credential reference); an + unstable guard value re-blocks a resource that is already running. +12. **Use Prerequisites for Cross-Component Dependencies.** Look for cross-component startup ordering orchestrated in + the controller instead of expressed with `WithPrerequisite` and `DependsOn`. Look for a prerequisite used to model + ongoing health coupling between components; a prerequisite is a one-time startup barrier, permanently satisfied + after the dependent component first passes through, not an ongoing health check. +13. **Use Feature Gates for Optional Components and Conditional Resources.** Look for an optional component or resource + branched on in the controller instead of gated with `WithFeatureGate` or `component.GatedBy`. Look for an optional + resource the component does not own (such as a read-only Secret reference behind an optional spec field) gated with + `GatedBy`, which deletes the resource on disable, when `IncludeWhen` (which omits without deleting) is the correct + mechanism, or the reverse. +14. **Provide a User-Override Escape Hatch as the Last Mutation.** Look for the absence of a documented user-override + mechanism for operator-emitted values. Where one exists, look for it registered anywhere but last among the + value-producing mutations, so it fails to reliably shadow the operator's own defaults. +15. **Fail Loudly Below the Supported Version Floor.** Look for a compat mutation that renders a best-effort + approximation for a version below the supported floor instead of returning an error from `Mutate`. +16. **Name Mutations for Golden Introspection.** Look for a mutation with no `Name` or a non-descriptive one, especially + a compat mutation not named after what it restores (for example `CompatV1Container`). An unnamed or vaguely named + mutation degrades error reporting and the `requires`/`forbids` lists in version-matrix golden manifests. +17. **Understand Participation Modes.** Look for `component.Auxiliary()` treated as "skipped" rather than "reconciled + but not required for Ready"; a failing auxiliary resource still fails the reconciliation. Look for a blocked guard + whose contribution to the condition is assumed to depend on participation mode; a blocked guard always contributes + to the condition regardless of `Auxiliary`. +18. **Grace Periods Are Convergence Time.** Look for a grace period set as a blanket safety margin rather than + reflecting the resource's actual expected convergence time, either too short for a workload with a large image pull + or slow readiness probe, or needlessly long in a way that delays detection of genuine failures. +19. **Handle Cluster-Scoped Resources Explicitly.** Look for a cluster-scoped resource (`ClusterRole`, + `ClusterRoleBinding`) owned by a namespace-scoped owner with no explicit `component.Delete()` or `DeleteWhen`, and + no finalizer on the owner CRD keeping it alive until those resources are removed; the framework cannot set an owner + reference across the scope boundary, so without explicit cleanup those resources are never garbage-collected. +20. **Name Resources to Avoid Multi-Tenant Collisions.** Look for a managed resource name not derived from the owner + name. For a cluster-scoped resource specifically, look for a name derived from the owner name alone without the + owner's namespace folded in; cluster-scoped resources share one global namespace, so two owners with the same name + in different namespaces collide. +21. **Name Conditions for the Audience Reading Them.** Look for a condition type named after the Kubernetes resource + type backing it (`StatefulSetHealthy`, `DeploymentReconciled`, `JobFinished`) rather than after the capability it + represents (`BackendReady`, `FrontendReady`, `MigrationComplete`). +22. **Pin Rendered Output Across Supported Versions.** Look for a supported version whose rendered output is not covered + by a golden. Look for a hand-written per-version golden loop where `goldengen.Resource` or `goldengen.Component` + should be used instead, and for a goldengen suite that never calls `AssertComplete` to prove every registered + mutation is exercised. Look for a golden that was not regenerated with `-update` and reviewed after a deliberate + baseline change, which would let an older regime silently drift. + +## Test coverage checks + +In addition to the guideline checklist, flag: + +- Mutations with no unit test exercising them. +- Components with no golden snapshot (`golden.AssertComponentYAML` or `goldengen.Component`) covering their rendered + output. +- Version-gated mutations with no `goldengen` matrix asserting which versions fire them, per + [Pin Rendered Output Across Supported Versions](#checklist). + +## Reporting format + +Report one finding per violation, with: + +- `file:line` for the offending code. +- The guideline title it violates (or "test coverage" for the checks above). +- Severity: `violation` for something the guidelines describe as a firm rule, `suggestion` for a softer recommendation + the guidelines phrase as a preference. +- A one-sentence explanation of why it is wrong. +- A concrete fix: the specific change that would resolve it. + +End the report with a summary line counting findings per severity, for example "3 violations, 2 suggestions." If the +scope has no findings, say so plainly rather than manufacturing minor nits. diff --git a/plugin/commands/review.md b/plugin/commands/review.md new file mode 100644 index 00000000..e31ca35b --- /dev/null +++ b/plugin/commands/review.md @@ -0,0 +1,10 @@ +--- +description: Review this operator against the operator-component-framework guidelines +argument-hint: [path or scope, defaults to the whole repo] +--- + +Review this operator codebase against the operator-component-framework guidelines. Scope (may be empty, meaning the +whole repository): $ARGUMENTS + +Dispatch the plugin's `reviewer` agent via the Agent tool with the scope above. When it returns, relay its findings to +the user unchanged, then offer to fix the violations it found, highest severity first. From d5e33c572b6cbaac8caffbb3cab4df115ad48366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:45:29 +0200 Subject: [PATCH 16/21] ci: validate Claude plugin and check reference sync drift Co-Authored-By: Claude Fable 5 --- .github/workflows/lint.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b554fecf..41c6b2f1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,4 +28,22 @@ jobs: nodejs - name: Run linter - run: make lint \ No newline at end of file + run: make lint + + plugin: + name: Validate Claude plugin + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@v6 + + - name: Check plugin references are in sync with docs + run: | + make sync-plugin + git diff --exit-code -- plugin + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Validate plugin and marketplace + run: claude plugin validate . \ No newline at end of file From 9e3200c0673907bc0038e34e7f7d961d75641afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:51:01 +0200 Subject: [PATCH 17/21] docs: maintainer rules and install instructions for the ocf Claude plugin Co-Authored-By: Claude Fable 5 --- .ai/base.md | 29 ++++++++++++++++++++--------- .github/copilot-instructions.md | 29 ++++++++++++++++++++--------- README.md | 15 +++++++++++++++ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/.ai/base.md b/.ai/base.md index 3a451665..8bada5d1 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -92,18 +92,29 @@ semantics. GoDoc is part of the public API surface. Update documentation in the **same response** as the code change — never leave them out of sync. -| Code area changed | Documentation to update | -| ------------------------------------------------- | ------------------------- | -| Component builder, reconciliation, status model | `docs/component.md` | -| Primitives, field application, editors, selectors | `docs/primitives.md` | -| Primitive implementations | `docs/primitives/*.md` | -| Generic building blocks, custom resource wrappers | `docs/custom-resource.md` | -| Operator structuring patterns, best practices | `docs/guidelines.md` | -| Any `pkg/` export visible in the quick start | `README.md` | -| Examples | `examples/*/README.md` | +| Code area changed | Documentation to update | +| ------------------------------------------------- | ------------------------------------------ | +| Component builder, reconciliation, status model | `docs/component.md` | +| Primitives, field application, editors, selectors | `docs/primitives.md` | +| Primitive implementations | `docs/primitives/*.md` | +| Generic building blocks, custom resource wrappers | `docs/custom-resource.md` | +| Operator structuring patterns, best practices | `docs/guidelines.md` | +| Any `pkg/` export visible in the quick start | `README.md` | +| Examples | `examples/*/README.md` | +| Any file under `docs/` synced into the plugin | Run `make sync-plugin` (CI fails on drift) | When updating documentation in markdown files, make sure to run `make fmt-md` for consistent formatting. +### Claude Code plugin + +The repository ships a Claude Code plugin for framework consumers in `plugin/` (marketplace manifest at +`.claude-plugin/marketplace.json`). Two rules keep it accurate: + +- Files under `plugin/skills/*/references/` are generated copies of `docs/` files. Never edit them by hand; edit the + source under `docs/` and run `make sync-plugin`. +- When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is + stale and update it in the same response. + ### Examples If you change a method signature, type name, or behaviour in `pkg/`, search `examples/` for usages and update them. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4e837b00..fc3ab0b3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,18 +92,29 @@ semantics. GoDoc is part of the public API surface. Update documentation in the **same response** as the code change — never leave them out of sync. -| Code area changed | Documentation to update | -| ------------------------------------------------- | ------------------------- | -| Component builder, reconciliation, status model | `docs/component.md` | -| Primitives, field application, editors, selectors | `docs/primitives.md` | -| Primitive implementations | `docs/primitives/*.md` | -| Generic building blocks, custom resource wrappers | `docs/custom-resource.md` | -| Operator structuring patterns, best practices | `docs/guidelines.md` | -| Any `pkg/` export visible in the quick start | `README.md` | -| Examples | `examples/*/README.md` | +| Code area changed | Documentation to update | +| ------------------------------------------------- | ------------------------------------------ | +| Component builder, reconciliation, status model | `docs/component.md` | +| Primitives, field application, editors, selectors | `docs/primitives.md` | +| Primitive implementations | `docs/primitives/*.md` | +| Generic building blocks, custom resource wrappers | `docs/custom-resource.md` | +| Operator structuring patterns, best practices | `docs/guidelines.md` | +| Any `pkg/` export visible in the quick start | `README.md` | +| Examples | `examples/*/README.md` | +| Any file under `docs/` synced into the plugin | Run `make sync-plugin` (CI fails on drift) | When updating documentation in markdown files, make sure to run `make fmt-md` for consistent formatting. +### Claude Code plugin + +The repository ships a Claude Code plugin for framework consumers in `plugin/` (marketplace manifest at +`.claude-plugin/marketplace.json`). Two rules keep it accurate: + +- Files under `plugin/skills/*/references/` are generated copies of `docs/` files. Never edit them by hand; edit the + source under `docs/` and run `make sync-plugin`. +- When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is + stale and update it in the same response. + ### Examples If you change a method signature, type name, or behaviour in `pkg/`, search `examples/` for usages and update them. diff --git a/README.md b/README.md index 715bb07d..13d89c7e 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,21 @@ Full documentation, including a step-by-step tutorial, is at The full Go API reference is on [pkg.go.dev](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework). +## Claude Code plugin + +The repository ships a [Claude Code](https://code.claude.com) plugin that teaches Claude the framework's concepts and +idioms: skills for components, primitives, custom resource wrappers, operator structure, and testing, plus scaffolding +commands and a guidelines reviewer. + +Install it from this repository: + +``` +/plugin marketplace add sourcehawk/operator-component-framework +/plugin install ocf +``` + +Then use `/ocf:docs `, `/ocf:new-component`, `/ocf:new-wrapper`, and `/ocf:review` inside your operator project. + ## Contributing Contributions are welcome. Open an issue to discuss significant changes before submitting a pull request. New code From 5a69d04f66470a49bfb0a48a72c3baaca6a57c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:07:02 +0200 Subject: [PATCH 18/21] ci: fail plugin sync check on untracked reference drift Co-Authored-By: Claude Fable 5 --- .github/workflows/lint.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 41c6b2f1..eea342e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -40,10 +40,12 @@ jobs: - name: Check plugin references are in sync with docs run: | make sync-plugin - git diff --exit-code -- plugin + git add -A plugin + git status --porcelain plugin + git diff --cached --exit-code -- plugin - name: Install Claude Code run: npm install -g @anthropic-ai/claude-code - name: Validate plugin and marketplace - run: claude plugin validate . \ No newline at end of file + run: claude plugin validate . From 43a9ce462b884812c09d3c8060a90c6a592f73d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:07:07 +0200 Subject: [PATCH 19/21] fix(plugin): add marketplace description and drop stray checklist anchor Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 1 + plugin/agents/reviewer.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 658b505b..0906b873 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,6 @@ { "name": "operator-component-framework", + "description": "Claude Code plugin marketplace for the operator-component-framework.", "owner": { "name": "sourcehawk", "url": "https://github.com/sourcehawk" diff --git a/plugin/agents/reviewer.md b/plugin/agents/reviewer.md index 8108306c..f7172914 100644 --- a/plugin/agents/reviewer.md +++ b/plugin/agents/reviewer.md @@ -113,8 +113,8 @@ In addition to the guideline checklist, flag: - Mutations with no unit test exercising them. - Components with no golden snapshot (`golden.AssertComponentYAML` or `goldengen.Component`) covering their rendered output. -- Version-gated mutations with no `goldengen` matrix asserting which versions fire them, per - [Pin Rendered Output Across Supported Versions](#checklist). +- Version-gated mutations with no `goldengen` matrix asserting which versions fire them, per Pin Rendered Output Across + Supported Versions. ## Reporting format From c5049c5078fae105b8d3838da69d449f6cecd471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:07:29 +0200 Subject: [PATCH 20/21] ci(plugin): pin nodejs via asdf in the plugin validation job The plugin job installed Claude Code with the runner's default Node toolchain while the sibling lint job pins nodejs from .tool-versions, so the job was exposed to CI-only failures when the ubuntu-latest default drifts. Install nodejs through asdf as the lint job does. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/lint.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index eea342e5..768eab19 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,6 +37,11 @@ jobs: - name: Clone the code uses: actions/checkout@v6 + - name: Install tools with asdf + uses: asdf-vm/actions/install@v4 + with: + only: nodejs + - name: Check plugin references are in sync with docs run: | make sync-plugin From bc54510df45798fdf1bd5a00cf3d409841390082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:59:55 +0200 Subject: [PATCH 21/21] feat(plugin): reconcile skills with declared data extraction API Update the distilled skill guidance, reviewer agent checklist, and docs command index for the declared data API merged in #165: concepts.Data cells, ExtractInto, WithDataGuard, WithOptionalData, build-time topology validation, DataTopology introspection, reconcile-start cell reset, and the suspension and preview caveats. Regenerate skill references via make sync-plugin. Co-Authored-By: Claude Fable 5 --- plugin/agents/reviewer.md | 20 +- plugin/commands/docs.md | 2 +- plugin/skills/building-components/SKILL.md | 70 ++++- .../references/component.md | 261 ++++++++++++++---- .../skills/custom-resource-wrappers/SKILL.md | 45 ++- .../references/custom-resource.md | 113 ++++++-- plugin/skills/structuring-operators/SKILL.md | 57 ++-- .../references/guidelines.md | 127 ++++++--- plugin/skills/testing-operators/SKILL.md | 5 + plugin/skills/using-primitives/SKILL.md | 12 +- .../using-primitives/references/primitives.md | 31 +-- .../references/primitives/clusterrole.md | 20 +- .../primitives/clusterrolebinding.md | 22 +- .../references/primitives/ingress.md | 2 +- .../references/primitives/networkpolicy.md | 24 +- .../references/primitives/pdb.md | 26 +- .../references/primitives/pvc.md | 24 +- .../references/primitives/role.md | 22 +- .../references/primitives/rolebinding.md | 22 +- .../references/primitives/service.md | 42 +-- .../references/primitives/serviceaccount.md | 22 +- .../references/primitives/unstructured.md | 40 +-- 22 files changed, 701 insertions(+), 308 deletions(-) diff --git a/plugin/agents/reviewer.md b/plugin/agents/reviewer.md index f7172914..e1cf673e 100644 --- a/plugin/agents/reviewer.md +++ b/plugin/agents/reviewer.md @@ -27,9 +27,9 @@ actually violated. primary container), with a mutation filling the gap instead. A baseline that is not a valid, readable resource on its own, whose validity depends on mutations having already run, is a violation. 2. **Mutations Are Pure Functions of the Spec.** Look for a mutation that reads live cluster state (a client `Get` or - `List` inside `Mutate`, or a closure variable meant to be populated by that same resource's own data extractor) to - decide what to write. Mutations run before data extraction on the same resource, so a mutation depending on its own - resource's extractor output is reading a zero value, not observed state. + `List` inside `Mutate`, or a `Require`/`Get` call on a data cell that same resource declares an `ExtractInto` for) to + decide what to write. Mutations run before declared extraction on the same resource, so a mutation reading its own + resource's cell always sees it unset: `Require` errors and `Get` reports absent, never observed state. 3. **Leave Version-Dependent Fields Empty in the Baseline.** Look for a version-dependent field, most commonly the container image, set directly in the baseline rather than left empty for a single mutation to own. Split ownership between the baseline and a mutation for the same field is the violation. @@ -61,10 +61,16 @@ actually violated. version-gated revert mutation per structural change. Look for a compat mutation that introduces a new field rather than only rolling one back. 11. **Use Data Extraction and Guards for Intra-Component Dependencies.** Look for a resource that assumes an - earlier-registered resource in the same component is ready without a `WithGuard` enforcing the wait. Look for a - guard keyed on a value that can transiently disappear (a replica count, a field cleared during a rolling update) - instead of a stable value (a status field written once, a provisioned IP, a generated credential reference); an - unstable guard value re-blocks a resource that is already running. + earlier-registered resource in the same component is ready without a `WithDataGuard` (or, for preconditions that are + not "a value exists", a `WithGuard`) enforcing the wait. Look for a value passed between resources through a shared + closure variable and a hand-written `WithGuard` instead of a declared `concepts.Data` cell with `ExtractInto` and + `WithDataGuard`, which bypasses build-time topology validation and keeps the dependency invisible to + `DataTopology()`. Look for a guard or extraction keyed on a value that can transiently disappear (a replica count, a + field cleared during a rolling update) instead of a stable value (a status field written once, a provisioned IP, a + generated credential reference); an unstable value re-blocks a resource that is already running, and with + `WithOptionalData` enrichment, which has no guard to hold the resource back, it makes the enriched field flap. In a + component that can be suspended, look for a mutation calling `Require()` on a cell produced by a read-only resource + or a `DeleteOnSuspend` resource; those cells stay absent during suspension, so the mutation must use `Get()`. 12. **Use Prerequisites for Cross-Component Dependencies.** Look for cross-component startup ordering orchestrated in the controller instead of expressed with `WithPrerequisite` and `DependsOn`. Look for a prerequisite used to model ongoing health coupling between components; a prerequisite is a one-time startup barrier, permanently satisfied diff --git a/plugin/commands/docs.md b/plugin/commands/docs.md index ffbdaf8c..8d1aeff6 100644 --- a/plugin/commands/docs.md +++ b/plugin/commands/docs.md @@ -8,7 +8,7 @@ Look up operator-component-framework documentation for: $ARGUMENTS Resolution order: 1. Search the bundled references first. They live under `${CLAUDE_PLUGIN_ROOT}/skills/*/references/`: - - `building-components/references/component.md`: component builder, lifecycle, status model, guards + - `building-components/references/component.md`: component builder, lifecycle, status model, guards, declared data - `using-primitives/references/primitives.md`: primitive concepts, mutation system, editors, selectors - `using-primitives/references/primitives/.md`: per-kind primitive builders and mutators - `custom-resource-wrappers/references/custom-resource.md`: wrapping CRD-backed types with pkg/generic diff --git a/plugin/skills/building-components/SKILL.md b/plugin/skills/building-components/SKILL.md index 9365c30e..c8492738 100644 --- a/plugin/skills/building-components/SKILL.md +++ b/plugin/skills/building-components/SKILL.md @@ -4,7 +4,7 @@ description: Use when creating or modifying a component built with the operator-component-framework (github.com/sourcehawk/operator-component-framework) - covers the component builder, resource registration, feature gates, prerequisites, the reconciliation lifecycle, conditions and the status model, grace periods, suspension, - ReconcileContext, FlushStatus, and guards. + ReconcileContext, FlushStatus, guards, and declared data cells. --- # Building Components @@ -54,9 +54,10 @@ deletion and gating options. See `references/component.md` for the full option m ## Registration order is execution order Resources reconcile sequentially in the order they were registered with `WithResource`. A resource registered earlier -can populate data (via a data extractor) that a later resource's guard or mutation depends on; the reverse never works. -Resources registered for deletion (`Delete()`, `DeleteWhen()`, or all managed resources when a feature gate is disabled) -are removed from the cluster in that same registration order in the final reconciliation step; the framework does not +can extract a value into a data cell that a later resource's data guard or mutation reads; the reverse never works, and +for declared data `Build()` rejects a registration order that cannot work (see Declared data below). Resources +registered for deletion (`Delete()`, `DeleteWhen()`, or all managed resources when a feature gate is disabled) are +removed from the cluster in that same registration order in the final reconciliation step; the framework does not reverse it. Design registration order around real dependencies, not convenience. ## Feature gates and prerequisites @@ -77,7 +78,8 @@ entirely. ## Reconciliation lifecycle -`comp.Reconcile(ctx, recCtx)` runs these steps, in order, every call: +`comp.Reconcile(ctx, recCtx)` runs these steps, in order, every call. Before step 1, every declared data cell is +cleared, so no value extracted during a previous reconcile can be observed during this one. 1. **Feature gate check.** Disabled -> delete all managed resources, condition `True/Disabled`. Gate error -> `FeatureGateError`, no further steps. @@ -88,8 +90,8 @@ entirely. suspension. 4. **Resource reconciliation.** Non-delete resources are processed sequentially in registration order: each resource's guard (if any) is checked, a blocked guard halts that resource and every later one, then the resource is applied - (managed) or fetched (read-only), and its data extractors run immediately so later resources can see the extracted - data. + (managed) or fetched (read-only), and its declared data extractions run immediately so later resources' data guards + and mutations can see the extracted values. 5. **Status aggregation.** The converging status of every processed resource (including a blocked-guard result) is collected. 6. **Condition update.** A new condition is derived from the aggregate status, the previous condition, and the grace @@ -134,14 +136,51 @@ prerequisite barrier is active, suspension is a no-op, since no resources exist cluster are created directly in their suspended state (for example, a Deployment created with zero replicas), so they are ready the instant suspension ends. +## Declared data + +Resources inside one component pass observed values to each other through typed, presence-aware **data cells**, created +in the component assembly function with `concepts.NewData[T]("name")`. The producer declares an extraction with the +primitive package's `ExtractInto` function (package-level, because a Go method cannot introduce the value type +parameter); it runs immediately after the resource is applied (managed) or fetched (read-only) and stores the result in +the cell. Consumers declare their reads on the builder: `WithDataGuard(cells...)` blocks the resource until every listed +cell is set, `WithOptionalData(cells...)` never gates and suits mutations that enrich the object only when the value is +there. Read a cell with `Require()` (errors with `concepts.ErrDataNotExtracted` when unset) or `Get()` (value plus +presence). + +```go +dbHost := concepts.NewData[string]("db-host") + +cmBuilder := configmap.NewBuilder(dbConfig(app)) +configmap.ExtractInto(cmBuilder, dbHost, func(cm corev1.ConfigMap) (string, error) { + return cm.Data["db-host"], nil +}) +``` + +`Build()` validates the topology: every cell a resource reads must have a producer registered **strictly earlier** (a +producer never satisfies its own read, since extraction runs after mutations on the same resource), and no two distinct +cells may share a name. Optional reads are validated too, so declare them even though they never gate. Sharing a cell +across components is unsupported; validation and the reconcile-start reset are per component. The built component +reports the declared flow through `DataTopology()` (`concepts.DataInspector`) without running any extraction. + +Two caveats. During suspension, declared extractions still run for managed resources, but cells produced by read-only +resources or by resources with `DeleteOnSuspend` stay absent, so a mutation depending on one of those must use `Get()` +rather than `Require()` if the component can ever be suspended. `Preview()` runs no extraction either, so a mutation +calling `Require()` fails the preview unless the test seeds the cell with `Set` first; return cells from the assembly +function so tests can reach them. + ## Guards and ReconcileContext -A guard is a precondition function registered on a resource with `WithGuard`, evaluated before that resource is applied. -It receives a copy of the resource's object and returns a `concepts.GuardStatusWithReason`. If it returns -`GuardStatusBlocked`, that resource and every resource registered after it are skipped for the cycle, and the condition -reports status `False` with reason `Blocked`. Combined with a data extractor on an earlier resource, guards let resource -B wait on a value resource A only produces once applied, without either resource knowing about the other's type. A guard -evaluation error is treated as a reconciliation failure (`Error`). Guards are not evaluated during suspension. +A guard is a precondition evaluated before a resource is applied. If it reports `GuardStatusBlocked`, that resource and +every resource registered after it are skipped for the cycle, and the condition reports status `False` with reason +`Blocked`. A guard evaluation error is treated as a reconciliation failure (`Error`). Guards are not evaluated during +suspension. + +There are two forms. A **data guard**, declared with `WithDataGuard(cells...)`, blocks until every listed data cell +holds a value; the framework generates both the guard and its reason (`waiting for data "db-host"`), so the message a +user reads cannot drift from the real dependency. A **custom guard**, registered with `WithGuard`, receives a copy of +the resource's object and returns a `concepts.GuardStatusWithReason` from arbitrary logic; keep it for preconditions +that are not "a value exists", such as a status phase reaching a specific value. A resource may use both: data guards +are evaluated first, and the custom guard is consulted only once every guarded cell is set. `ReconcileContext` carries everything a reconcile pass needs: `Client`, `Scheme`, `Recorder`, an optional `Metrics` recorder, and `Owner` (the CRD instance that owns the component). Build one per reconcile from your controller and pass @@ -160,8 +199,9 @@ conditions in one reconcile and persist them in one write, instead of racing a w the resource diverge unpredictably across cluster versions; gate the value with a mutation instead. - Giving one component more than one logical condition collapses distinct failure modes into a single status, hiding which underlying concern actually broke; split it into one component per condition instead. -- Registering resources without regard to their real dependency order silently breaks guards and data extractors that - assume an earlier resource already ran; registration order must match the actual dependency order. +- Registering resources without regard to their real dependency order breaks guards and data flow that assume an earlier + resource already ran. `Build()` turns the mistake into an error where the flow is declared with data cells, but custom + guards still break silently; registration order must match the actual dependency order. ## Ground truth diff --git a/plugin/skills/building-components/references/component.md b/plugin/skills/building-components/references/component.md index 910499b7..f8040702 100644 --- a/plugin/skills/building-components/references/component.md +++ b/plugin/skills/building-components/references/component.md @@ -240,6 +240,9 @@ message: `comp.Reconcile(ctx, recCtx)` runs the following steps on every call. They match the authoritative order in the `Reconcile` GoDoc. +Every declared [data cell](#declared-data) is cleared before step 1 runs, so no value extracted during a previous +reconcile can be observed during this one. + 1. **Feature gate check.** If a feature gate is set and disabled, all managed resources are deleted and the condition is set to `True/Disabled`. No further processing occurs. A gate evaluation error sets `FeatureGateError`. 2. **Prerequisite check.** If prerequisites are registered and the initialization barrier is still active, all @@ -250,8 +253,8 @@ message: remaining steps are skipped. Guards are not evaluated during suspension. 4. **Resource reconciliation.** All non-delete resources are processed sequentially in registration order, managed or read-only alike. For each resource: its guard (if any) is evaluated and a blocked guard stops that resource and all - later ones; the resource is applied (managed) or fetched (read-only); its data extractors run immediately, making - extracted data available to subsequent resources' guards and mutations. + later ones; the resource is applied (managed) or fetched (read-only); its declared data extractions run immediately, + making the extracted values available to subsequent resources' data guards and mutations. 5. **Status aggregation.** The converging status of every processed resource is collected, including any blocked-guard result. 6. **Condition update.** A new component condition is derived from the aggregate resource status, the previous @@ -262,7 +265,8 @@ message: ```mermaid flowchart TD - Start([Reconcile]) --> Gate{Feature gate set?} + Start([Reconcile]) --> Reset[Clear declared data cells] + Reset --> Gate{Feature gate set?} Gate -->|disabled| DelAll[Delete all resources] --> Disabled([True / Disabled]) Gate -->|enabled or unset| Prereq{Barrier active
and prereqs set?} Prereq -->|unmet| NotMet([False / PrerequisiteNotMet]) @@ -275,7 +279,7 @@ flowchart TD DelMarked --> End([Return; controller calls FlushStatus]) ``` -A read-only resource registered before a managed one can extract data that feeds the managed resource's guard or +A read-only resource registered before a managed one can extract data that feeds the managed resource's data guard or mutations within the same reconcile cycle. Read-only resources that implement `ObservationRecorder` have the fetched object recorded back onto them so later inspection sees live cluster state; resources built from `generic.BaseResource` do this automatically. Managed resources are applied with Server-Side Apply and receive a controller owner reference, @@ -294,6 +298,25 @@ in a cluster-free render. `Preview` therefore returns the full desired set, incl skip behind a blocked guard, which keeps the snapshot deterministic and focused on baseline construction, mutation wiring, and registration order. +No extraction runs during a preview either, so every [data cell](#declared-data) is unset. A mutation that calls `Get` +degrades quietly and simply omits the enriched field. A mutation that calls `Require` returns an error wrapping +`concepts.ErrDataNotExtracted`, which fails the whole preview. Tests that render such a resource must seed the cell +first: + +```go +comp, dbHost, err := BuildComponent(owner) +if err != nil { + return err +} +dbHost.Set("postgres.default.svc") // stand in for the value a real reconcile would extract + +objs, err := comp.Preview() +``` + +Return the cells from your component assembly function so tests can reach them, as +[`examples/extraction-and-guards`](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/extraction-and-guards) +does. + Each managed resource must implement [`concepts.Previewable`](primitives.md#lifecycle-interfaces) (`Preview()`). All built-in primitives satisfy it through `generic.BaseResource`. A custom resource must implement it to be previewable; without it, `Component.Preview` returns an error for that resource. `Preview` is the natural input for whole-component @@ -326,6 +349,9 @@ implements the same interface so version-matrix golden generation can introspect [`concepts.MutationInspector`](primitives.md#lifecycle-interfaces) for the contract and the [Testing](testing.md) guide for how it drives version-matrix goldens. +The data-flow counterpart is `concepts.DataInspector` (`DataTopology()`), which reports the declared flow of every data +cell through the component without running any extraction. See [Inspecting the topology](#inspecting-the-topology). + ### Cluster-scoped resources When a component manages cluster-scoped resources (such as `ClusterRole` or `PersistentVolume`) and the owner CRD is @@ -454,6 +480,13 @@ Resources with `DeleteOnSuspend` enabled are **not** created if already absent; suspended, which avoids a create-then-delete loop on every reconcile while the component stays suspended. Resources that are not `Suspendable` are left in place. +Guards are not evaluated during suspension, but [declared extractions](#declared-data) still run for each managed +resource in registration order, so a mutation that calls `Require()` on a cell an earlier managed resource produces +still works while the component is suspended. Cells produced by read-only resources (which are not fetched during +suspension) or by resources with `DeleteOnSuspend` (which are skipped once absent) stay absent for as long as the +component is suspended. A mutation that depends on one of those must use `Get()` rather than `Require()` if the +component can ever be suspended. + ## ReconcileContext `ReconcileContext` carries all dependencies for a reconciliation pass. Pass it from your controller on each call: @@ -520,63 +553,170 @@ them in a single write. Persisting after each component would race the component [Keep Controllers Thin](guidelines.md#keep-controllers-thin) and [One Component Per Logical Condition](guidelines.md#one-component-per-logical-condition). +## Declared Data + +Resources inside one component pass observed values to each other through **data cells**. A cell is created in the +component assembly function, written by a declared extraction on an earlier resource, and read by later resources' +guards and mutations during the same reconcile. + +```go +dbHost := concepts.NewData[string]("db-host") +``` + +`concepts.Data[T]` is named, typed, and presence-aware, which is what separates "not extracted yet" from "extracted as +the empty string". + +| Method | Returns | +| ---------------------- | ---------------------------------------------------------------------------------- | +| `Name() string` | The diagnostic name, used in guard reasons, validation errors, and topology output | +| `IsSet() bool` | Whether the cell currently holds a value | +| `Get() (T, bool)` | The value and its presence; the zero value of `T` when unset | +| `Require() (T, error)` | The value, or an error wrapping `concepts.ErrDataNotExtracted` and naming the cell | + +There is deliberately no panicking accessor: reconciler code must degrade to conditions and requeues, never crash the +manager. Cell identity is the pointer, not the name. + +`Set` and `Clear` are exported because the extraction runner and the reconcile-start reset live in other packages. +Calling them from resource code bypasses topology validation and is unsupported. Seeding a cell in a test before +[previewing](#previewing-desired-state) is the one intended manual use of `Set`. + +### Declaring a write + +Every primitive package exports an `ExtractInto` function that records "this resource produces this cell". It is a +package-level function rather than a builder method because a Go method cannot introduce the value type parameter. + +```go +configmap.ExtractInto(cmBuilder, dbHost, func(cm corev1.ConfigMap) (string, error) { + return cm.Data["db-host"], nil +}) +``` + +The function runs immediately after the resource is applied (managed) or fetched (read-only), and the framework stores +its result in the cell and marks it present. If it returns an error, the reconcile fails with that error. Extracting +several values from one object means several `ExtractInto` calls, one per cell. + +Custom resource wrappers expose the same shape by delegating to `generic.ExtractInto`; see the +[custom resource guide](custom-resource.md#5-implement-the-builder). + +### Declaring a read + +Two builder methods record "this resource reads this cell", and both accept any number of cells: + +- `WithDataGuard(cells...)` blocks the resource until every listed cell is set. See [Guards](#guards). +- `WithOptionalData(cells...)` does not gate. Use it when the resource proceeds either way and a mutation enriches the + object only when the value is there. + +Both modes are validated and both show up in the topology. The +[Guidelines](guidelines.md#use-data-extraction-and-guards-for-intra-component-dependencies) page has the table of +consumption modes and when to pick each. + +### Reset at the start of each reconcile + +`Reconcile` clears every declared cell before it does anything else. Cells created in the assembly function are already +scoped to one reconcile; the reset is a hardening so that a cell which somehow outlives its assembly function still +cannot leak a value from one pass into the next. Sharing a cell across components is unsupported, because both the reset +and the validation below are per component. + +### Build-time validation + +`Build()` walks the resources in registration order and rejects a component whose data flow cannot work. + +Every cell a resource reads must have a producer registered **strictly earlier**. A producer never satisfies its own +read, since extraction runs after mutations on the same resource: + +```text +resource "v1/Secret/default/db-credentials" reads data "db-host" but no earlier resource produces it +``` + +No two distinct cells may share a name. Pointer identity is what the checks run on; the name check exists so diagnostics +and topology output stay unambiguous: + +```text +resource "v1/ConfigMap/default/app-config" in component "database" declares data "db-host", but a distinct cell already uses that name; data names must be unique within a component +``` + +Multiple resources may produce the same cell. That is allowed, and at runtime the last registered producer's extraction +wins, because each one overwrites the cell as it runs. + +Only resources that actually reconcile participate. Declarations on resources registered with `Delete()`, +`DeleteWhen()`, or `OrphanWhen()` never run an extraction and are not considered. A resource whose `GatedBy` gate is +disabled is moved to the delete set at registration, so if it was the only producer of a cell, `Build()` fails with the +no-earlier-producer error; that is intentional, and it surfaces the broken data flow at build time rather than leaving a +reader permanently blocked at runtime. + +### Inspecting the topology + +The built component satisfies `concepts.DataInspector`. `DataTopology()` returns one `concepts.DataEdge` per declared +cell, in first-producer registration order, without running any extraction: + +```go +for _, edge := range comp.DataTopology() { + fmt.Printf("%s: produced by %v, guarded by %v, optional for %v\n", + edge.Data, edge.Producers, edge.Guarded, edge.Optional) +} +``` + +`Producers`, `Guarded`, and `Optional` hold resource identities in registration order. This is the data-flow counterpart +of [`concepts.MutationInspector`](primitives.md#lifecycle-interfaces): nothing in the reconcile path calls it, and tests +can assert a component's declared data flow the same way they assert its registered mutations. + ## Guards -Guards let resources within a component express runtime dependencies on each other. A guard is a precondition function -registered on a resource and evaluated before the resource is applied. If the guard returns `Blocked`, the resource and -all resources registered after it are skipped for that reconcile cycle. +Guards let resources within a component express runtime dependencies on each other. A guard is a precondition evaluated +before the resource is applied. If it reports `Blocked`, the resource and all resources registered after it are skipped +for that reconcile cycle. -Combined with per-resource data extraction, guards enable indirect dependency graphs: resource A is applied first, its -data extractor populates a shared variable, and resource B's guard checks that variable before allowing B to proceed. +There are two forms. A **data guard**, declared with `WithDataGuard(cells...)`, blocks until every listed +[data cell](#declared-data) holds a value; the framework writes both the guard and its reason. A **custom guard**, +registered with `WithGuard`, runs arbitrary logic against the resource object. A resource may use both: data guards are +evaluated first, and the custom guard is consulted only once every guarded cell is set. -### Registering a guard +### Blocking on declared data -Guards are registered on the resource builder with `WithGuard`. The guard receives a copy of the resource object and -returns a `concepts.GuardStatusWithReason`. The following example shows the full pattern: a first resource extracts a -value after being applied, and a second resource guards against running before that value is available. +Reach for `WithDataGuard` whenever the precondition is "an earlier resource produced this value". The example below is +the full pattern: a config source declares an extraction, and a consumer declares a guard and a mutation that read it. ```go -func buildBackendComponent(owner *v1alpha1.WebApp, endpoint *string) (*component.Component, error) { - // First resource: a config source. After it is applied, the data extractor - // reads a value from the live object into *endpoint. - configRes, err := static.NewBuilder(newBackendConfig(owner)). - WithDataExtractor(func(obj uns.Unstructured) error { - *endpoint = obj.Object["data"].(map[string]any)["endpoint"].(string) - return nil - }). - Build() +func buildBackendComponent(owner *v1alpha1.WebApp) (*component.Component, error) { + endpoint := concepts.NewData[string]("backend-endpoint") + + // First resource: a config source. Once it is applied, the declared + // extraction reads a value out of the live object and into the cell. + configBuilder := static.NewBuilder(newBackendConfig(owner)) + static.ExtractInto(configBuilder, endpoint, func(obj uns.Unstructured) (string, error) { + value, _, err := uns.NestedString(obj.Object, "data", "endpoint") + return value, err + }) + configRes, err := configBuilder.Build() if err != nil { return nil, err } - // Second resource: a consumer that needs the extracted endpoint. Its guard - // blocks until *endpoint is populated earlier in this same reconcile cycle; - // the mutation then injects the value at Mutate() time. - consumerRes, err := static.NewBuilder(newBackendConsumer(owner)). - WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { - if *endpoint == "" { - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusBlocked, - Reason: "waiting for backend endpoint", - }, nil + // Second resource: a consumer that needs the endpoint. The data guard blocks + // it until the cell is set earlier in this same reconcile cycle; the mutation + // then injects the value at Mutate() time. + consumerBuilder := static.NewBuilder(newBackendConsumer(owner)) + consumerBuilder.WithDataGuard(endpoint) + consumerBuilder.WithMutation(unstruct.Mutation{ + Name: "set-endpoint", + Mutate: func(m *unstruct.Mutator) error { + value, err := endpoint.Require() + if err != nil { + return err } - return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil - }). - WithMutation(unstruct.Mutation{ - Name: "set-endpoint", - Mutate: func(m *unstruct.Mutator) error { - m.EditContent(func(e *editors.UnstructuredContentEditor) error { - return e.SetNestedString(*endpoint, "spec", "endpoint") - }) - return nil - }, - }). - Build() + m.EditContent(func(e *editors.UnstructuredContentEditor) error { + return e.SetNestedString(value, "spec", "endpoint") + }) + return nil + }, + }) + consumerRes, err := consumerBuilder.Build() if err != nil { return nil, err } - // Registration order matters: the config source must be registered before the consumer. + // Registration order matters: the config source must be registered before the + // consumer, and Build() rejects the component if it is not. return component.NewComponentBuilder(). WithName("backend"). WithConditionType("BackendReady"). @@ -586,11 +726,36 @@ func buildBackendComponent(owner *v1alpha1.WebApp, endpoint *string) (*component } ``` -The guard receives the resource's object but need not use it. Guards that only check external state (closure variables -populated by prior extractors) can ignore the parameter. +The reason comes from the cells, so a blocked consumer reports `waiting for data "backend-endpoint"` without anyone +writing that string, and it cannot drift when the dependency changes. Guarding on several cells names every missing one: +`waiting for data "backend-endpoint", "api-token"`. + +### Registering a custom guard + +Use `WithGuard` for preconditions that are not "a value exists". The guard receives a copy of the resource object and +returns a `concepts.GuardStatusWithReason`. + +```go +res, err := deployment.NewBuilder(base). + WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) { + if !owner.Spec.LicenseAccepted { + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusBlocked, + Reason: "waiting for the license terms to be accepted", + }, nil + } + return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil + }). + Build() +``` + +The guard receives the resource's object but need not use it, as above. Passing nil to `WithGuard` clears any previously +registered custom guard; it does not affect declared data guards. ### Guard behavior +- Data guards are evaluated before the custom guard. If any guarded cell is unset, the resource is `Blocked` with the + generated reason and the custom guard is never called. - Guards are evaluated in registration order, before each resource is applied. - When a guard returns `Blocked`, the blocked resource contributes a `Blocked` status to the component condition regardless of its participation mode, and all resources after it are skipped entirely. This override exists because a @@ -606,7 +771,7 @@ A blocked guard produces a condition like: type: BackendReady status: "False" reason: Blocked -message: "waiting for backend endpoint" +message: 'waiting for data "backend-endpoint"' ``` The `Blocked` status is not sticky. It is self-reinforcing only because the guard re-evaluates on every reconcile; when diff --git a/plugin/skills/custom-resource-wrappers/SKILL.md b/plugin/skills/custom-resource-wrappers/SKILL.md index 06d34cea..2302ae66 100644 --- a/plugin/skills/custom-resource-wrappers/SKILL.md +++ b/plugin/skills/custom-resource-wrappers/SKILL.md @@ -16,7 +16,7 @@ Reach for a custom resource wrapper only when the kind an operator manages has n defined by the project or a third-party operator, or a standard Kubernetes kind the built-in set does not yet wrap. `pkg/generic` supplies the building blocks (reconciliation mechanics, the plan-and-apply mutation flow, suspension, -guards, data extraction). A wrapper package combines these with kind-specific identity, status, and mutator logic, the +guards, declared data). A wrapper package combines these with kind-specific identity, status, and mutator logic, the same way the built-in primitives do. If the CRD has no typed Go struct, the unstructured static primitive (`pkg/primitives/unstructured/static`) manages it @@ -48,9 +48,9 @@ interfaces: | **Task** | `generic.TaskResource` | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | Run-to-completion workloads | | **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | -Every generic resource also satisfies `concepts.Previewable` and `concepts.MutationInspector`, regardless of category. -The category choice determines which status handlers are required or meaningful (see Choosing a category below) and -which methods the resource wrapper needs to implement. +Every generic resource also satisfies `concepts.Previewable`, `concepts.MutationInspector`, `concepts.DataProducer`, and +`concepts.DataConsumer`, regardless of category. The category choice determines which status handlers are required or +meaningful (see Choosing a category below) and which methods the resource wrapper needs to implement. ### 2. Define the mutation type alias @@ -174,8 +174,21 @@ func (b *Builder) Build() (*Resource, error) { } ``` -`generic.WrapGuard` and `generic.WrapExtractor` convert value-receiver callbacks (`func(T)`) into the pointer-receiver -form the generic layer expects, so the wrapper's public API can take the kind by value. +Expose declared data the same way every built-in primitive does: forward `WithDataGuard(cells ...concepts.DataCell)` and +`WithOptionalData(cells ...concepts.DataCell)` to the base as fluent methods, and add a package-level `ExtractInto` +function. It is package-level rather than a builder method because a Go method cannot introduce the value type +parameter; `generic.ExtractInto` takes a `*generic.BaseBuilder`, which every category builder embeds. + +```go +func ExtractInto[V any]( + b *Builder, cell *concepts.Data[V], fn func(examplev1.MessageQueue) (V, error), +) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} +``` + +`generic.WrapGuard` and `generic.WrapExtraction` convert value-receiver callbacks (`func(T)` and `func(T) (V, error)`) +into the pointer-receiver form the generic layer expects, so the wrapper's public API can take the kind by value. ### 6. Implement the resource @@ -184,16 +197,20 @@ package exports a concrete type rather than a generic one; list the interfaces i `Preview()`: it satisfies `concepts.Previewable`, and without it `component.Preview()` fails at runtime and golden snapshot tests cannot render the resource. `RegisteredMutations()` and `FiringSet()` satisfy `concepts.MutationInspector` and are used by version-matrix golden generation to introspect which mutations a resource -registers and which fire at a given version; delegate both to the base. Forward `RecordObservation` whenever the -resource may be registered read-only with a data extractor, since the framework feeds the fetched cluster object back to -the resource before extraction runs. +registers and which fire at a given version; delegate both to the base. Forward `ProducedData` and `ConsumedData` +whenever the resource can take part in a component's data flow, which is always if the builder exposes `ExtractInto`, +`WithDataGuard`, or `WithOptionalData`: they satisfy `concepts.DataProducer` and `concepts.DataConsumer`, and without +them build-time topology validation silently passes, `DataTopology()` omits the resource, and its cells are never +cleared at the start of a reconcile. Forward `RecordObservation` whenever the resource may be registered read-only and +declares an extraction, since the framework feeds the fetched cluster object back to the resource before extraction +runs. Which methods to include depends on category: a Static resource needs only `Identity`, `Object`, `Mutate`, -`GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, and `FiringSet`. Workload, Task, -and Integration resources add `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, and `SuspensionStatus`; Workload and -Integration additionally add `GraceStatus`. For Task and Integration resources, `ConvergingStatus` returns -`concepts.CompletionStatusWithReason` and `concepts.OperationalStatusWithReason` respectively, matching the generic base -method signature. +`GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, `RecordObservation`, `Preview`, `RegisteredMutations`, and +`FiringSet`. Workload, Task, and Integration resources add `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, and +`SuspensionStatus`; Workload and Integration additionally add `GraceStatus`. For Task and Integration resources, +`ConvergingStatus` returns `concepts.CompletionStatusWithReason` and `concepts.OperationalStatusWithReason` +respectively, matching the generic base method signature. ### 7. Define feature mutations diff --git a/plugin/skills/custom-resource-wrappers/references/custom-resource.md b/plugin/skills/custom-resource-wrappers/references/custom-resource.md index f7f70e7e..80e97a91 100644 --- a/plugin/skills/custom-resource-wrappers/references/custom-resource.md +++ b/plugin/skills/custom-resource-wrappers/references/custom-resource.md @@ -2,7 +2,7 @@ This guide is for operator authors who need to manage a Kubernetes object that the [built-in primitives](primitives.md) do not cover. The built-in set handles the common kinds (Deployments, StatefulSets, ConfigMaps, Services, and more) and -is highly customizable through status handlers, suspension logic, mutations, and data extractors. Reach for a custom +is highly customizable through status handlers, suspension logic, mutations, and declared data. Reach for a custom resource only when the kind you manage has no matching primitive: - A **custom CRD** defined by your project or a third-party operator. @@ -68,8 +68,8 @@ interfaces. For the full description of each interface and the runtime string va | **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | In addition to the category-specific interfaces, every generic resource also satisfies -[`concepts.Previewable`](primitives.md#lifecycle-interfaces) and `concepts.MutationInspector`, and your wrapper exposes -both. They are covered in [Step 6](#6-implement-the-resource). +[`concepts.Previewable`](primitives.md#lifecycle-interfaces), `concepts.MutationInspector`, `concepts.DataProducer`, and +`concepts.DataConsumer`, and your wrapper exposes all four. They are covered in [Step 6](#6-implement-the-resource). The rest of the guide uses Workload as the primary example. The pattern is identical for the other categories, with fewer handlers to implement. @@ -457,9 +457,20 @@ func (b *Builder) WithGuard( return b } -// WithDataExtractor registers a data extractor to run after the resource is processed. -func (b *Builder) WithDataExtractor(extractor func(examplev1.MessageQueue) error) *Builder { - b.base.WithDataExtractor(generic.WrapExtractor(extractor)) +// WithDataGuard declares that the resource reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason; component Build validates that a producer for each cell is registered +// earlier. Data guards are evaluated before any custom guard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the resource reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) return b } @@ -487,16 +498,40 @@ func (b *Builder) Build() (*Resource, error) { } return &Resource{base: genericRes}, nil } + +// ExtractInto declares that this MessageQueue produces the value of cell. fn +// computes the value from a copy of the reconciled MessageQueue; the framework +// stores it in the cell and marks it present, immediately after the object is +// applied or fetched. This is a package-level function because a Go method +// cannot introduce the extra type parameter V. +func ExtractInto[V any]( + b *Builder, cell *concepts.Data[V], fn func(examplev1.MessageQueue) (V, error), +) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} ``` The builder exposes `WithCustomSuspendStatus`, `WithCustomSuspendMutation`, and `WithCustomSuspendDeletionDecision` the same way if callers need to override suspension behavior after construction; they are omitted above for brevity. +Callers then use the package-level form, mirroring every built-in primitive: + +```go +replicas := concepts.NewData[int32]("queue-replicas") + +builder := messagequeue.NewBuilder(mq) +messagequeue.ExtractInto(builder, replicas, func(q examplev1.MessageQueue) (int32, error) { + return q.Status.ReadyReplicas, nil +}) +``` + !!! note "Builder conventions" - - **`generic.WrapGuard` and `generic.WrapExtractor`** convert value-receiver callbacks (`func(T)`) into the - pointer-receiver form (`func(*T)`) the generic layer expects, so your public API can take the kind by value. The - built-in builders use both. + - **`generic.WrapGuard` and `generic.WrapExtraction`** convert value-receiver callbacks (`func(T)` and + `func(T) (V, error)`) into the pointer-receiver form (`func(*T)` and `func(*T) (V, error)`) the generic layer + expects, so your public API can take the kind by value. The built-in builders use both. + - **Reach the embedded base for `ExtractInto`.** `generic.ExtractInto` takes a `*generic.BaseBuilder`, so pass + `&b.base.BaseBuilder`. Every category builder embeds it. - **Register defaults in the constructor.** Set the handlers your CRD has meaningful semantics for, then let callers override them per resource. - **Return `*Builder` from every method** for fluent chaining. @@ -531,6 +566,8 @@ import ( // - concepts.Suspendable (DeleteOnSuspend, Suspend, SuspensionStatus) // - concepts.Guardable (GuardStatus) // - concepts.DataExtractable (ExtractData) +// - concepts.DataProducer (ProducedData) +// - concepts.DataConsumer (ConsumedData) // - concepts.ObservationRecorder (RecordObservation) // - concepts.Previewable (Preview) // - concepts.MutationInspector (RegisteredMutations, FiringSet) @@ -578,6 +615,16 @@ func (r *Resource) ExtractData() error { return r.base.ExtractData() } +// ProducedData returns the cells this resource declares extractions into. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the resource's declared data reads, blocking and optional alike. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + func (r *Resource) RecordObservation(observed client.Object) error { return r.base.RecordObservation(observed) } @@ -600,6 +647,8 @@ func (r *Resource) FiringSet() ([]string, error) { // Compile-time guarantee that the wrapper exposes the inspection surface. var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) ``` !!! warning "Do not omit `Preview`" @@ -611,18 +660,24 @@ var _ concepts.MutationInspector = (*Resource)(nil) them, but [version-matrix golden generation](testing.md) uses them to introspect which mutations a resource registers and which fire at a given version. Delegate both to the base, as shown. -Forward `RecordObservation` whenever the resource may be registered read-only with a data extractor. The framework feeds -the fetched cluster object back to the resource before extraction runs; without it, the extractor would see the inert -base passed to the builder rather than live cluster state. +Forward `ProducedData` and `ConsumedData` whenever the resource can take part in a component's data flow, which is +always if your builder exposes `ExtractInto`, `WithDataGuard`, or `WithOptionalData`. They satisfy +`concepts.DataProducer` and `concepts.DataConsumer`. Without them the component sees no declarations, so +[build-time topology validation](component.md#build-time-validation) silently passes, `DataTopology()` omits the +resource, and its cells are never cleared at the start of a reconcile. + +Forward `RecordObservation` whenever the resource may be registered read-only and declares an extraction. The framework +feeds the fetched cluster object back to the resource before extraction runs; without it, the extraction would see the +inert base passed to the builder rather than live cluster state. Which methods to include depends on the category: -| Category | Methods to include | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workload | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | -| Static | `Identity`, `Object`, `Mutate`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | -| Task | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | -| Integration | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Category | Methods to include | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workload | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Static | `Identity`, `Object`, `Mutate`, `GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Task | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | +| Integration | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, `RecordObservation`, `Preview`, `RegisteredMutations`, `FiringSet` | For task and integration resources, `ConvergingStatus` returns `concepts.CompletionStatusWithReason` and `concepts.OperationalStatusWithReason` respectively, matching the generic base method signature. @@ -763,10 +818,10 @@ implications. ### Static resources Static resources have the simplest implementation. They do not participate in convergence, grace, or suspension -reporting. The builder uses `generic.NewStaticBuilder`, which supports `WithMutation`, `WithGuard`, and -`WithDataExtractor`. The resource wrapper needs only `Identity`, `Object`, `Mutate`, `GuardStatus`, `ExtractData`, -`RecordObservation`, `Preview`, `RegisteredMutations`, and `FiringSet`. `pkg/primitives/configmap` is a complete -reference. +reporting. The builder uses `generic.NewStaticBuilder`, which supports `WithMutation`, `WithGuard`, `WithDataGuard`, and +`WithOptionalData`, plus a package-level `ExtractInto`. The resource wrapper needs only `Identity`, `Object`, `Mutate`, +`GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, `RecordObservation`, `Preview`, `RegisteredMutations`, and +`FiringSet`. `pkg/primitives/configmap` is a complete reference. ### Task resources @@ -851,13 +906,13 @@ logic. ## Reference -| Package | Contains | -| ------------------------ | -------------------------------------------------------------- | -| `pkg/generic` | Generic resource types, builders, `WrapGuard`, `WrapExtractor` | -| `pkg/feature` | `Mutation`, `Gate`, `VersionGate`, `NewVersionGate` | -| `pkg/component/concepts` | Lifecycle interfaces and status type constants | -| `pkg/component` | Component builder, resource registration, reconciliation | -| `pkg/primitives/*` | Built-in implementations to use as references | +| Package | Contains | +| ------------------------ | ------------------------------------------------------------------------------ | +| `pkg/generic` | Generic resource types, builders, `ExtractInto`, `WrapGuard`, `WrapExtraction` | +| `pkg/feature` | `Mutation`, `Gate`, `VersionGate`, `NewVersionGate` | +| `pkg/component/concepts` | Lifecycle interfaces, status type constants, `NewData`, `DataCell` | +| `pkg/component` | Component builder, resource registration, reconciliation | +| `pkg/primitives/*` | Built-in implementations to use as references | For a complete, runnable wrapper of a third-party CRD (using the unstructured static builder rather than a typed struct), see `examples/custom-resource`. diff --git a/plugin/skills/structuring-operators/SKILL.md b/plugin/skills/structuring-operators/SKILL.md index 998e5ff9..3c120afe 100644 --- a/plugin/skills/structuring-operators/SKILL.md +++ b/plugin/skills/structuring-operators/SKILL.md @@ -18,7 +18,8 @@ around. **Mutations are pure functions of the spec.** A mutation computes its output from the owner spec and other build-time inputs only. It never reads a resource's live cluster state to decide what to write, and within a single resource it -runs before that resource's own data extractors, so a mutation cannot see data its own resource has not yet produced. +runs before that resource's own declared extractions, so a mutation can never read a data cell its own resource +produces. **One component per logical condition.** If users would ask "is the backend ready?" and "is the frontend ready?" as separate questions, those are separate components, each reporting its own condition. Combine resources into one @@ -41,7 +42,7 @@ checklist: a change that violates one of these rules is a candidate for rework, | Mutation Ordering and Container-Name Dependencies | Use broad, name-independent selectors for version-independent mutations, and register name-specific mutations before any compat mutation that renames the container. | | Layer Mutations in a Fixed Order | Order a resource's mutations into fixed layers: defaults, compat, overrides, then checksum, so the pipeline reads the same way for every workload. | | Prefer Reverting Compat Mutations Over Forward Mutations | Keep the baseline at the latest shape and add a version-gated revert mutation per structural change, rather than holding the baseline at an old shape and patching it forward. | -| Use Data Extraction and Guards for Intra-Component Dependencies | When one resource depends on data from another resource in the same component, register a data extractor on the source and a guard on the dependent. | +| Use Data Extraction and Guards for Intra-Component Dependencies | When one resource depends on data from another resource in the same component, declare the flow with a data cell: `ExtractInto` on the producer, `WithDataGuard` or `WithOptionalData` on the consumer. | | Use Prerequisites for Cross-Component Dependencies | When a component cannot start until another component is ready, attach a prerequisite instead of orchestrating ordering in the controller. | | Use Feature Gates for Optional Components and Conditional Resources | Gate optional pieces with a feature gate so the framework owns the full lifecycle, including deletion when the gate flips off. | | Provide a User-Override Escape Hatch as the Last Mutation | Apply a documented user-override mutation as the last value-producing mutation so the user's input shadows the operator's own defaults. | @@ -59,37 +60,35 @@ checklist: a change that violates one of these rules is a candidate for rework, Three mechanisms cover three different dependency shapes. Picking the wrong one either breaks silently or forces orchestration logic back into the controller. -**Data extraction and guards, for a dependency between two resources inside one component.** Register a data extractor -on the source resource and a guard on the dependent resource. Do not assume a resource is ready just because it was -registered earlier; the guard is what actually enforces the wait. +**Declared data, for a dependency between two resources inside one component.** Create a typed cell with +`concepts.NewData[T]`, declare the producer's extraction with the primitive package's `ExtractInto` function, and +declare the consumer's read with `WithDataGuard` (block until present, read with `Require`) or `WithOptionalData` (never +gates; a mutation reads with `Get` and enriches only when the value is there). `Build()` rejects the component unless a +producer is registered strictly earlier than every reader, so a broken flow is a build error rather than a resource +waiting forever. ```go -var roleARN string - -roleRes, _ := static.NewBuilder(cloudRole(app)). - WithDataExtractor(func(obj uns.Unstructured) error { - roleARN, _, _ = unstructured.NestedString(obj.Object, "status", "arn") - return nil - }). - Build() - -bucketRes, _ := static.NewBuilder(cloudBucket(app)). - WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { - if roleARN == "" { - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusBlocked, - Reason: "waiting for cloud role ARN", - }, nil - } - return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil - }). - Build() +roleARN := concepts.NewData[string]("cloud-role-arn") + +roleBuilder := static.NewBuilder(cloudRole(app)) +static.ExtractInto(roleBuilder, roleARN, func(obj uns.Unstructured) (string, error) { + arn, _, err := uns.NestedString(obj.Object, "status", "arn") + return arn, err +}) +roleRes, _ := roleBuilder.Build() + +bucketBuilder := static.NewBuilder(cloudBucket(app)) +bucketBuilder.WithDataGuard(roleARN) +bucketRes, _ := bucketBuilder.Build() ``` -The guard re-evaluates every reconcile, so it naturally re-blocks the dependent if its input disappears. Prefer stable -values (a status field written once, a generated credential reference) over values that can transiently clear during -normal operation (a replica count, a field cleared mid rolling-update), or the guard will re-block a resource that is -already running. +`WithDataGuard` generates both the guard and its reason (`waiting for data "cloud-role-arn"`), so the message users read +cannot drift from the real dependency; keep `WithGuard` for preconditions that are not "a value exists". A data guard +re-evaluates every reconcile, so it naturally re-blocks the dependent if its input disappears. Prefer stable values (a +status field written once, a generated credential reference) over values that can transiently clear during normal +operation (a replica count, a field cleared mid rolling-update), or the guard will re-block a resource that is already +running. This applies doubly to optional enrichment, which has no guard to hold the resource back: a source value that +comes and goes makes the enriched field flap. **Prerequisites, for a dependency between two components.** Attach `WithPrerequisite` on the dependent component rather than sequencing the components in the controller. diff --git a/plugin/skills/structuring-operators/references/guidelines.md b/plugin/skills/structuring-operators/references/guidelines.md index a5f13427..ea19755e 100644 --- a/plugin/skills/structuring-operators/references/guidelines.md +++ b/plugin/skills/structuring-operators/references/guidelines.md @@ -59,9 +59,9 @@ A mutation must be a pure function of the owner spec and other inputs available resource's live cluster state to decide what to write. This is not only a style preference. Within a single resource, the framework applies mutations **before** data -extraction runs, so a closure variable populated by a data extractor on the same builder still holds its zero value when -that resource's mutations execute. Data extraction passes observed state from an **earlier** resource to a **later** -resource, not back into a resource's own mutations. +extraction runs, so a cell written by an extraction declared on the same builder is still unset when that resource's +mutations execute. Declared data passes observed state from an **earlier** resource to a **later** resource, not back +into a resource's own mutations. A mutation that produces the same desired object for the same spec, regardless of what currently exists in the cluster, aligns with Server-Side Apply's declarative model and keeps reconciliation predictable. If you find yourself wanting to @@ -202,9 +202,9 @@ status even while controller-runtime backs off. ## Resource Registration Order Is Execution Order -Resources reconcile in the exact order they are registered with `WithResource`. This is deliberate: guards and data -extractors depend on it, and reading the calls top to bottom tells you the order with no implicit dependency graph to -reconstruct. +Resources reconcile in the exact order they are registered with `WithResource`. This is deliberate: guards and declared +data extraction depend on it, and reading the calls top to bottom tells you the order with no implicit dependency graph +to reconstruct. Register dependencies before dependents. A common per-component bundle reads as a dependency chain: read-only Secret references first (with [`BlockOnAbsence`](component.md#resource-registration-options) so an absent Secret blocks the @@ -222,8 +222,9 @@ comp, err := component.NewComponentBuilder(). Build() ``` -The flip side is that reordering these calls can silently break data flow between extractors and guards, so document the -dependency where one exists. +Reordering these calls breaks data flow between a resource that extracts a value and a later one that reads it. Where +the flow is declared with [data cells](#use-data-extraction-and-guards-for-intra-component-dependencies), `Build()` +turns that mistake into a build error instead of a resource that waits forever. ## Mutation Ordering and Container-Name Dependencies @@ -319,39 +320,79 @@ the number of supported versions, and each one deletes cleanly when its version ## Use Data Extraction and Guards for Intra-Component Dependencies -When one resource depends on data from another resource in the **same** component, register a data extractor on the -source and a guard on the dependent. Do not assume a resource is ready just because it was registered earlier. +When one resource depends on data from another resource in the **same** component, declare the flow rather than passing +a variable between two closures. A `concepts.Data[T]` cell is a named, typed, presence-aware container: the producer +declares an extraction into it, the consumer declares its read, and `Build()` verifies the two are registered in an +order that can actually work. ```go -var roleARN string +func buildDatabaseComponent(app *v1alpha1.WebApp) (*component.Component, error) { + dbHost := concepts.NewData[string]("db-host") + + cmBuilder := configmap.NewBuilder(dbConfig(app)) + configmap.ExtractInto(cmBuilder, dbHost, func(cm corev1.ConfigMap) (string, error) { + return cm.Data["db-host"], nil + }) + cmRes, err := cmBuilder.Build() + if err != nil { + return nil, err + } -roleRes, _ := static.NewBuilder(cloudRole(app)). - WithDataExtractor(func(obj uns.Unstructured) error { - roleARN, _, _ = unstructured.NestedString(obj.Object, "status", "arn") - return nil - }). - Build() + secretBuilder := secret.NewBuilder(dbCredentials(app)) + secretBuilder.WithDataGuard(dbHost) + secretBuilder.WithMutation(secret.Mutation{ + Name: "db-host-entry", + Mutate: func(m *secret.Mutator) error { + host, err := dbHost.Require() + if err != nil { + return err + } + m.SetStringData("db-host", host) + return nil + }, + }) + secretRes, err := secretBuilder.Build() + if err != nil { + return nil, err + } -bucketRes, _ := static.NewBuilder(cloudBucket(app)). - WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { - if roleARN == "" { - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusBlocked, - Reason: "waiting for cloud role ARN", - }, nil - } - return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil - }). - Build() + // The producer must be registered before the consumer; Build() enforces it. + return component.NewComponentBuilder(). + WithName("database"). + WithConditionType("DatabaseReady"). + WithResource(cmRes). + WithResource(secretRes). + Build() +} ``` -A blocked guard surfaces as a `Blocked` condition reason, so users can see why a resource has not been created yet. The -shared variable is scoped to one reconcile, which prevents state leaking between reconciles. +Create cells inside the component assembly function so they stay scoped to a single reconcile. The component clears +every declared cell before it reconciles anything, so a cell that somehow outlives its assembly function still cannot +carry a value into the next pass. Sharing one cell across components is unsupported: validation and reset are per +component. + +`WithDataGuard` generates both the guard and its reason, so the message a user reads (`waiting for data "db-host"`) +cannot drift from the real dependency. A blocked data guard surfaces as the same `Blocked` condition reason any guard +produces. Keep `WithGuard` for preconditions that are not "a value exists", such as a status phase reaching a specific +value. + +Three consumption modes cover the useful cases: + +| Mode | Declaration | Accessor | Behavior when absent | +| -------------------------- | ------------------ | --------- | ------------------------------------------------- | +| Block until present | `WithDataGuard` | `Require` | Resource waits, the condition explains why | +| Proceed, enrich when ready | `WithOptionalData` | `Get` | Mutation skips; the field appears on a later pass | +| Proceed, fail loudly | `WithOptionalData` | `Require` | Mutation errors, the component reports a failure | + +`WithOptionalData` never gates. Declare it anyway: the build-time check then still verifies that some earlier resource +produces the cell, because an optional read with no producer can never be satisfied and is almost always a mistake, and +the dependency stays visible to introspection. -Prefer **stable** values for guard conditions. A guard re-evaluates every reconcile, so a value that can transiently -disappear (a replica count, a field cleared during a rolling update) will re-block a resource that is already running. -Good targets appear once and persist: a status field written by a controller, a provisioned IP, a generated credential -reference. +Prefer **stable** values. A guard re-evaluates every reconcile, so a value that can transiently disappear (a replica +count, a field cleared during a rolling update) will re-block a resource that is already running. Good targets appear +once and persist: a status field written by a controller, a provisioned IP, a generated credential reference. This +applies doubly to optional enrichment, which has no guard to hold the resource back: a source value that comes and goes +makes the enriched field flap, rewriting the consuming resource on every swing. ## Use Prerequisites for Cross-Component Dependencies @@ -440,19 +481,23 @@ func extraEnv(app *v1alpha1.WebApp) deployment.Mutation { Because `EnsureEnvVars` replaces existing entries by name, registering this mutation after the operator's own env mutations lets a user value shadow an operator-emitted one without you enumerating every overridable field. -A related use of a final mutation is **secret-rotation restart**: each read-only Secret has a data extractor that hashes -its contents into a shared map, and a final mutation stamps that map onto the pod template as annotations through -`EditPodTemplateMetadata`. A Secret rotation changes a hash, which changes the pod template, which triggers a rolling -restart. Keep the map empty during preview so golden snapshots stay stable. +A related use of a final mutation is **secret-rotation restart**: each read-only Secret declares an extraction that +hashes its contents into a cell, the workload declares those cells with `WithOptionalData`, and a final mutation stamps +the hashes it can read onto the pod template as annotations through `EditPodTemplateMetadata`. A Secret rotation changes +a hash, which changes the pod template, which triggers a rolling restart. Optional reads keep the workload moving when a +Secret has not been fetched yet, and cells are unset in a cluster-free preview, so golden snapshots stay stable without +any special casing. ```go -func checksumAnnotations(hashes map[string]string) deployment.Mutation { +func checksumAnnotations(hashes map[string]*concepts.Data[string]) deployment.Mutation { return deployment.Mutation{ Name: "ChecksumAnnotations", Mutate: func(m *deployment.Mutator) error { m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { - for k, v := range hashes { - e.EnsureAnnotation("checksum/"+k, v) + for name, cell := range hashes { + if hash, ok := cell.Get(); ok { + e.EnsureAnnotation("checksum/"+name, hash) + } } return nil }) diff --git a/plugin/skills/testing-operators/SKILL.md b/plugin/skills/testing-operators/SKILL.md index c657bf11..5cbd1ec5 100644 --- a/plugin/skills/testing-operators/SKILL.md +++ b/plugin/skills/testing-operators/SKILL.md @@ -62,6 +62,11 @@ Serializing one without a scheme fails with an incomplete-`TypeMeta` error, so p `golden.ComponentPreviewer` (`Preview() ([]client.Object, error)`). All built-in primitives satisfy `Previewer` through `generic.BaseResource`, and a built `*component.Component` satisfies `ComponentPreviewer` directly. +`Preview` runs no declared data extraction, so every data cell is unset during a golden render. A mutation that calls +`Get` quietly omits the enriched field; one that calls `Require` fails the preview with an error wrapping +`concepts.ErrDataNotExtracted`. Seed the cell before rendering (`dbHost.Set("postgres.default.svc")`), and have the +component assembly function return its cells so tests can reach them. + ```go var update = flag.Bool("update", false, "update golden files") diff --git a/plugin/skills/using-primitives/SKILL.md b/plugin/skills/using-primitives/SKILL.md index a687ceb3..fd55d5a8 100644 --- a/plugin/skills/using-primitives/SKILL.md +++ b/plugin/skills/using-primitives/SKILL.md @@ -4,7 +4,8 @@ description: Use when creating or editing Kubernetes resource primitives with the operator-component-framework - primitive builders and categories, baseline desired state, the mutation system, boolean and version feature gating (NewBooleanGate, NewVersionGate), mutation editors, container selectors, server-side apply behaviour, workload-kind-agnostic mutations - (WorkloadMutator), and unstructured primitives. + (WorkloadMutator), declared data on primitive builders (ExtractInto, WithDataGuard, WithOptionalData), and + unstructured primitives. --- # Using Primitives @@ -156,6 +157,15 @@ The interface deliberately omits what is not common to all three kinds: per-kind `EditStatefulSetSpec`, `EditDaemonSetSpec`), `EnsureReplicas` (no replica field on DaemonSet), and StatefulSet-only VolumeClaimTemplate methods. Reach for the concrete mutator type for those. +## Declared data on primitive builders + +Every primitive builder participates in a component's declared data flow the same way: a package-level +`ExtractInto(builder, cell, fn)` function declares that the resource produces a `concepts.Data[V]` cell (package-level +because a Go method cannot introduce the value type parameter), and the builder methods `WithDataGuard(cells...)` and +`WithOptionalData(cells...)` declare its reads. Component `Build()` validates that every read has a producer registered +earlier. The mechanics, consumption modes, and validation rules live in the `ocf:building-components` skill; verify a +kind's exact `ExtractInto` signature with `go doc` on its package. + ## Server-side apply The framework reconciles with Server-Side Apply: each primitive builds its desired state (baseline plus all active diff --git a/plugin/skills/using-primitives/references/primitives.md b/plugin/skills/using-primitives/references/primitives.md index 6443a554..d8dd29d5 100644 --- a/plugin/skills/using-primitives/references/primitives.md +++ b/plugin/skills/using-primitives/references/primitives.md @@ -88,7 +88,7 @@ A primitive participates in status aggregation by implementing one or more lifec | `Completable` | `Completed`, `TaskRunning`, `TaskPending`, `TaskFailing` | Jobs and task primitives | | `Operational` | `Operational`, `OperationPending`, `OperationFailing` | Services, Ingresses, CronJobs | | `Guardable` | `Blocked` | Resources with runtime preconditions | -| `DataExtractable` | _(no status, side-effecting)_ | Resources that expose post-sync data | +| `DataExtractable` | _(no status, side-effecting)_ | Resources that publish post-sync values to cells | !!! warning "`Guardable` reports only `Blocked`" @@ -360,8 +360,8 @@ those. ## Usage Examples The example below builds a frontend `Deployment` for a hypothetical `WebApp` operator, adds a version-gated sidecar -mutation, targets multiple containers, guards on a value extracted from an earlier resource, and registers the result -with a component. +mutation, targets multiple containers, guards on a value declared and extracted by an earlier resource, and registers +the result with a component. === "Building and registering a primitive" @@ -398,6 +398,10 @@ with a component. }, } + // apiEndpoint is created by the component assembly function and written by + // an earlier resource's declared extraction. + apiEndpoint := concepts.NewData[string]("api-endpoint") + res, err := deployment.NewBuilder(base). // 2. A mutation: add a sidecar, gated on a version constraint, and // configure it. The sidecar is added then edited in one pass. @@ -427,17 +431,10 @@ with a component. return nil }, }). - // 4. A guard: do not apply until a precondition (here, a value - // extracted from an earlier resource) is satisfied. - WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) { - if apiEndpoint == "" { - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusBlocked, - Reason: "waiting for backend endpoint", - }, nil - } - return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil - }). + // 4. A data guard: do not apply until the earlier resource has extracted + // the endpoint. The framework generates the blocked reason from the + // cell name, here `waiting for data "api-endpoint"`. + WithDataGuard(apiEndpoint). Build() if err != nil { return nil, err @@ -462,9 +459,9 @@ with a component. !!! note "Guards versus prerequisites" - A [guard](component.md#guards) handles a dependency **within** one component: an earlier resource extracts data after - it is applied, and a later resource's guard checks that data before proceeding. For a dependency **between** - components (the frontend cannot start until the backend is ready), use + A [guard](component.md#guards) handles a dependency **within** one component: an earlier resource extracts a value + into a [data cell](component.md#declared-data) after it is applied, and a later resource blocks on that cell before + proceeding. For a dependency **between** components (the frontend cannot start until the backend is ready), use [prerequisites](component.md#prerequisites) on the component builder instead. See [Guards](component.md#guards) for the full behavioral contract. diff --git a/plugin/skills/using-primitives/references/primitives/clusterrole.md b/plugin/skills/using-primitives/references/primitives/clusterrole.md index 19657583..cd7a24eb 100644 --- a/plugin/skills/using-primitives/references/primitives/clusterrole.md +++ b/plugin/skills/using-primitives/references/primitives/clusterrole.md @@ -192,17 +192,23 @@ Pass `nil` to clear the aggregation rule. Within a single feature, the last `Set ## Data Extraction -`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled ClusterRole: +`clusterrole.ExtractInto` declares that this ClusterRole produces the value of a data cell. The function receives a +value copy of the reconciled ClusterRole and runs immediately after each sync cycle: ```go -resource, err := clusterrole.NewBuilder(base). - WithDataExtractor(func(cr rbacv1.ClusterRole) error { - sharedState.ClusterRoleName = cr.Name - return nil - }). - Build() +roleName := concepts.NewData[string]("viewer-cluster-role") + +builder := clusterrole.NewBuilder(base) +clusterrole.ExtractInto(builder, roleName, func(cr rbacv1.ClusterRole) (string, error) { + return cr.Name, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(roleName)` or read it +opportunistically with `WithOptionalData(roleName)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go diff --git a/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md b/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md index bc549d36..900d440b 100644 --- a/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md +++ b/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md @@ -174,18 +174,24 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Data Extraction -`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled -ClusterRoleBinding. Use it to surface binding metadata to other resources: +`clusterrolebinding.ExtractInto` declares that this ClusterRoleBinding produces the value of a data cell, which is how +you surface binding metadata to other resources. The function receives a value copy of the reconciled ClusterRoleBinding +and runs immediately after each sync cycle: ```go -resource, err := clusterrolebinding.NewBuilder(base). - WithDataExtractor(func(crb rbacv1.ClusterRoleBinding) error { - sharedState.ClusterRoleBindingName = crb.Name - return nil - }). - Build() +bindingName := concepts.NewData[string]("viewer-cluster-role-binding") + +builder := clusterrolebinding.NewBuilder(base) +clusterrolebinding.ExtractInto(builder, bindingName, func(crb rbacv1.ClusterRoleBinding) (string, error) { + return crb.Name, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(bindingName)` or read it +opportunistically with `WithOptionalData(bindingName)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go diff --git a/plugin/skills/using-primitives/references/primitives/ingress.md b/plugin/skills/using-primitives/references/primitives/ingress.md index 6ccd685a..b878d5d3 100644 --- a/plugin/skills/using-primitives/references/primitives/ingress.md +++ b/plugin/skills/using-primitives/references/primitives/ingress.md @@ -11,7 +11,7 @@ metadata. | **Operational** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | | **Graceful** | Reports `Degraded` until a load balancer IP or hostname is assigned, then `Healthy` | | **Suspendable** | No-op by default. Ingress is left in place; backend returns 502/503 when the backing service is down | -| **DataExtractable** | Reads assigned load balancer addresses after each sync cycle via `WithDataExtractor` | +| **DataExtractable** | Reads assigned load balancer addresses after each sync cycle via `ExtractInto` | | **Mutation pipeline** | Typed editors for metadata and Ingress spec (rules, TLS, class name, default backend) | See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface diff --git a/plugin/skills/using-primitives/references/primitives/networkpolicy.md b/plugin/skills/using-primitives/references/primitives/networkpolicy.md index b49dfa9e..62128187 100644 --- a/plugin/skills/using-primitives/references/primitives/networkpolicy.md +++ b/plugin/skills/using-primitives/references/primitives/networkpolicy.md @@ -10,7 +10,7 @@ resource, providing a structured mutation API for managing pod selectors, ingres | **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | | **Mutation pipeline** | Typed editors for NetworkPolicy spec and object metadata, with a `Raw()` escape hatch | | **Append semantics** | Ingress and egress rules have no unique key; `AppendIngressRule`/`AppendEgressRule` append unconditionally | -| **DataExtractable** | Reads values back from the reconciled NetworkPolicy after each sync cycle via `WithDataExtractor` | +| **DataExtractable** | Reads values back from the reconciled NetworkPolicy after each sync cycle via `ExtractInto` | See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface reports. @@ -169,20 +169,24 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Data Extraction -Use `WithDataExtractor` to read values from the reconciled NetworkPolicy after each sync cycle. This is useful when -downstream resources need to observe the final applied policy (for example, its resource version or assigned labels): +`networkpolicy.ExtractInto` declares that this NetworkPolicy produces the value of a data cell. This is useful when +downstream resources need to observe the final applied policy (for example, its resource version or assigned labels). +The function receives a value copy of the reconciled NetworkPolicy after each sync cycle: ```go -var policyName string +policyName := concepts.NewData[string]("frontend-network-policy") -resource, err := networkpolicy.NewBuilder(base). - WithDataExtractor(func(np networkingv1.NetworkPolicy) error { - policyName = np.Name - return nil - }). - Build() +builder := networkpolicy.NewBuilder(base) +networkpolicy.ExtractInto(builder, policyName, func(np networkingv1.NetworkPolicy) (string, error) { + return np.Name, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(policyName)` or read it +opportunistically with `WithOptionalData(policyName)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go diff --git a/plugin/skills/using-primitives/references/primitives/pdb.md b/plugin/skills/using-primitives/references/primitives/pdb.md index ceab4477..6653eaf8 100644 --- a/plugin/skills/using-primitives/references/primitives/pdb.md +++ b/plugin/skills/using-primitives/references/primitives/pdb.md @@ -154,18 +154,24 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Data Extraction -Use `WithDataExtractor` to read generated or server-populated fields after each sync cycle. The extractor receives a -value copy of the reconciled PDB: +`pdb.ExtractInto` declares that this PDB produces the value of a data cell, which is how you read generated or +server-populated fields. The function receives a value copy of the reconciled PDB after each sync cycle: ```go -pdb.NewBuilder(base). - WithDataExtractor(func(p policyv1.PodDisruptionBudget) error { - // p.Status.ExpectedPods is populated by the Kubernetes PDB controller - myComponent.ExpectedPods = p.Status.ExpectedPods - return nil - }) +expectedPods := concepts.NewData[int32]("expected-pods") + +builder := pdb.NewBuilder(base) +pdb.ExtractInto(builder, expectedPods, func(p policyv1.PodDisruptionBudget) (int32, error) { + // Status.ExpectedPods is populated by the Kubernetes PDB controller. + return p.Status.ExpectedPods, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(expectedPods)` or read it +opportunistically with `WithOptionalData(expectedPods)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go @@ -237,5 +243,5 @@ protects. If a mutation renames pods or changes their labels, update the PDB sel **Register mutations in dependency order.** If mutation B relies on state set by mutation A, register A first. **Use data extraction to read `Status` fields.** Fields like `Status.ExpectedPods`, `Status.CurrentHealthy`, and -`Status.DisruptionsAllowed` are populated by the Kubernetes PDB controller after reconciliation. Access them through -`WithDataExtractor` rather than inspecting the baseline object. +`Status.DisruptionsAllowed` are populated by the Kubernetes PDB controller after reconciliation. Declare an extraction +into a data cell rather than inspecting the baseline object. diff --git a/plugin/skills/using-primitives/references/primitives/pvc.md b/plugin/skills/using-primitives/references/primitives/pvc.md index 777ce034..c8298096 100644 --- a/plugin/skills/using-primitives/references/primitives/pvc.md +++ b/plugin/skills/using-primitives/references/primitives/pvc.md @@ -225,20 +225,22 @@ func ExpandedStorageMutation(version string) pvc.Mutation { } } -var boundVolumeName string +boundVolume := concepts.NewData[string]("bound-volume") -resource, err := pvc.NewBuilder(base). +builder := pvc.NewBuilder(base). WithMutation(StorageRequestMutation(owner.Spec.Version)). - WithMutation(ExpandedStorageMutation(owner.Spec.Version)). - WithDataExtractor(func(p corev1.PersistentVolumeClaim) error { - boundVolumeName = p.Spec.VolumeName - return nil - }). - Build() + WithMutation(ExpandedStorageMutation(owner.Spec.Version)) + +pvc.ExtractInto(builder, boundVolume, func(p corev1.PersistentVolumeClaim) (string, error) { + return p.Spec.VolumeName, nil +}) + +resource, err := builder.Build() ``` On versions 2.0.0 and above, `ExpandedStorageMutation` fires and sets the storage request to 50Gi. On earlier versions, -only the base 10Gi request is applied. After each reconcile cycle, the data extractor captures the bound volume name. +only the base 10Gi request is applied. After each reconcile cycle, the declared extraction captures the bound volume +name into the `bound-volume` cell. ## Guidance @@ -249,8 +251,8 @@ invalid requests. **Prefer `WithCustomSuspendDeletionDecision` over deleting PVCs manually.** If you need PVCs to be cleaned up during suspension, register a deletion decision handler rather than deleting them in a mutation. -**Use `WithDataExtractor` to read bound volume information.** The bound volume name and actual allocated capacity are -server-assigned. Read them with a data extractor after reconciliation rather than caching them in mutation logic. +**Use `ExtractInto` to read bound volume information.** The bound volume name and actual allocated capacity are +server-assigned. Declare an extraction into a data cell rather than caching them in mutation logic. **Use string status values in conditions.** The operational status values that appear in conditions are the runtime strings `"Operational"`, `"OperationPending"`, and `"OperationFailing"`, not the Go constant identifiers. diff --git a/plugin/skills/using-primitives/references/primitives/role.md b/plugin/skills/using-primitives/references/primitives/role.md index 29664c65..575d1e67 100644 --- a/plugin/skills/using-primitives/references/primitives/role.md +++ b/plugin/skills/using-primitives/references/primitives/role.md @@ -154,18 +154,24 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Data Extraction -`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled Role. Use it to -surface the applied rules or metadata to other resources: +`role.ExtractInto` declares that this Role produces the value of a data cell, which is how you surface the applied rules +or metadata to other resources. The function receives a value copy of the reconciled Role and runs immediately after +each sync cycle: ```go -resource, err := role.NewBuilder(base). - WithDataExtractor(func(r rbacv1.Role) error { - sharedState.RoleName = r.Name - return nil - }). - Build() +roleName := concepts.NewData[string]("app-role") + +builder := role.NewBuilder(base) +role.ExtractInto(builder, roleName, func(r rbacv1.Role) (string, error) { + return r.Name, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(roleName)` or read it +opportunistically with `WithOptionalData(roleName)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go diff --git a/plugin/skills/using-primitives/references/primitives/rolebinding.md b/plugin/skills/using-primitives/references/primitives/rolebinding.md index 0017bfa3..cc99af66 100644 --- a/plugin/skills/using-primitives/references/primitives/rolebinding.md +++ b/plugin/skills/using-primitives/references/primitives/rolebinding.md @@ -157,18 +157,24 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Data Extraction -`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled RoleBinding. Use -it to surface binding metadata to other resources: +`rolebinding.ExtractInto` declares that this RoleBinding produces the value of a data cell, which is how you surface +binding metadata to other resources. The function receives a value copy of the reconciled RoleBinding and runs +immediately after each sync cycle: ```go -resource, err := rolebinding.NewBuilder(base). - WithDataExtractor(func(rb rbacv1.RoleBinding) error { - sharedState.RoleBindingName = rb.Name - return nil - }). - Build() +bindingName := concepts.NewData[string]("app-role-binding") + +builder := rolebinding.NewBuilder(base) +rolebinding.ExtractInto(builder, bindingName, func(rb rbacv1.RoleBinding) (string, error) { + return rb.Name, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(bindingName)` or read it +opportunistically with `WithOptionalData(bindingName)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go diff --git a/plugin/skills/using-primitives/references/primitives/service.md b/plugin/skills/using-primitives/references/primitives/service.md index 609efd26..78b1a795 100644 --- a/plugin/skills/using-primitives/references/primitives/service.md +++ b/plugin/skills/using-primitives/references/primitives/service.md @@ -154,20 +154,23 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Data Extraction -Use `WithDataExtractor` to read values from the reconciled Service after each sync cycle, such as the assigned ClusterIP -or LoadBalancer ingress: +`service.ExtractInto` declares that this Service produces the value of a data cell, such as the assigned ClusterIP or +LoadBalancer ingress. The function receives a value copy of the reconciled Service after each sync cycle: ```go -var assignedIP string +clusterIP := concepts.NewData[string]("backend-cluster-ip") -resource, err := service.NewBuilder(base). - WithDataExtractor(func(svc corev1.Service) error { - assignedIP = svc.Spec.ClusterIP - return nil - }). - Build() +builder := service.NewBuilder(base) +service.ExtractInto(builder, clusterIP, func(svc corev1.Service) (string, error) { + return svc.Spec.ClusterIP, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(clusterIP)` or read it +opportunistically with `WithOptionalData(clusterIP)`. See [Declared Data](../component.md#declared-data). + ## Operational Status The Service primitive implements `concepts.Operational`. The default handler reports: @@ -278,16 +281,17 @@ func MetricsPortMutation(version string, enabled bool) service.Mutation { } } -var assignedIP string +clusterIP := concepts.NewData[string]("backend-cluster-ip") -resource, err := service.NewBuilder(base). +builder := service.NewBuilder(base). WithMutation(BaseServiceMutation(owner.Spec.Version)). - WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)). - WithDataExtractor(func(svc corev1.Service) error { - assignedIP = svc.Spec.ClusterIP - return nil - }). - Build() + WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + +service.ExtractInto(builder, clusterIP, func(svc corev1.Service) (string, error) { + return svc.Spec.ClusterIP, nil +}) + +resource, err := builder.Build() ``` When `EnableMetrics` is true, the Service exposes both the HTTP and metrics ports. When false, only HTTP is configured. @@ -305,5 +309,5 @@ repeated calls with the same name produce the same result. **Leave Services in place during suspension.** The no-op default is correct for most Services. Only override `WithCustomSuspendDeletionDecision` when your use case requires explicitly removing the Service during suspension. -**Use `WithDataExtractor` for assigned addresses.** ClusterIP and LoadBalancer ingress are server-assigned. Read them -with a data extractor after reconciliation rather than caching them in mutation logic. +**Use `ExtractInto` for assigned addresses.** ClusterIP and LoadBalancer ingress are server-assigned. Declare an +extraction into a data cell rather than caching them in mutation logic. diff --git a/plugin/skills/using-primitives/references/primitives/serviceaccount.md b/plugin/skills/using-primitives/references/primitives/serviceaccount.md index 7d936ee3..992d5f27 100644 --- a/plugin/skills/using-primitives/references/primitives/serviceaccount.md +++ b/plugin/skills/using-primitives/references/primitives/serviceaccount.md @@ -121,18 +121,24 @@ The pointed-to value is snapshotted at registration time, so later caller-side c ## Data Extraction -`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled ServiceAccount. -Use it to surface generated fields to other resources: +`serviceaccount.ExtractInto` declares that this ServiceAccount produces the value of a data cell, which is how you +surface generated fields to other resources. The function receives a value copy of the reconciled ServiceAccount and +runs immediately after each sync cycle: ```go -resource, err := serviceaccount.NewBuilder(base). - WithDataExtractor(func(sa corev1.ServiceAccount) error { - sharedState.ServiceAccountName = sa.Name - return nil - }). - Build() +saName := concepts.NewData[string]("app-service-account") + +builder := serviceaccount.NewBuilder(base) +serviceaccount.ExtractInto(builder, saName, func(sa corev1.ServiceAccount) (string, error) { + return sa.Name, nil +}) + +resource, err := builder.Build() ``` +Resources registered later in the same component block on the cell with `WithDataGuard(saName)` or read it +opportunistically with `WithOptionalData(saName)`. See [Declared Data](../component.md#declared-data). + ## Full Example ```go diff --git a/plugin/skills/using-primitives/references/primitives/unstructured.md b/plugin/skills/using-primitives/references/primitives/unstructured.md index 93368655..a5ea96f8 100644 --- a/plugin/skills/using-primitives/references/primitives/unstructured.md +++ b/plugin/skills/using-primitives/references/primitives/unstructured.md @@ -237,19 +237,24 @@ was called. ## Data Extraction -All four variants support data extraction. The extractor receives a value copy of the reconciled object after each sync -cycle: +All four variants support declared extraction through a package-level `ExtractInto`, which records that the resource +produces the value of a data cell. The function receives a value copy of the reconciled object after each sync cycle: ```go -builder.WithDataExtractor(func(obj uns.Unstructured) error { - ip, found, _ := uns.NestedString(obj.Object, "status", "atProvider", "ipAddress") - if found { - myComponent.ResourceIP = ip - } - return nil +providerIP := concepts.NewData[string]("provider-ip") + +builder := static.NewBuilder(obj) +static.ExtractInto(builder, providerIP, func(obj uns.Unstructured) (string, error) { + ip, _, err := uns.NestedString(obj.Object, "status", "atProvider", "ipAddress") + return ip, err }) ``` +An absent field yields the zero value and the cell is still marked present, which a data guard treats as satisfied. +Return an error from the function instead when a missing field should fail the reconcile and leave the cell unset. +Resources registered later in the same component block on the cell with `WithDataGuard(providerIP)` or read it +opportunistically with `WithOptionalData(providerIP)`. See [Declared Data](../component.md#declared-data). + ## Suspension Handlers The non-static variants support custom suspension behavior. All three handlers default to safe no-ops when omitted. @@ -287,7 +292,9 @@ obj.SetGroupVersionKind(schema.GroupVersionKind{ obj.SetName("app-db") obj.SetNamespace(owner.Namespace) -resource, err := integration.NewBuilder(obj). +dbEndpoint := concepts.NewData[string]("db-endpoint") + +builder := integration.NewBuilder(obj). WithMutation(unstruct.Mutation{ Name: "connection-config", Feature: feature.NewVersionGate(owner.Spec.Version, nil), @@ -311,13 +318,14 @@ resource, err := integration.NewBuilder(obj). default: return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing, Reason: phase}, nil } - }). - WithDataExtractor(func(o uns.Unstructured) error { - endpoint, _, _ := uns.NestedString(o.Object, "status", "endpoint") - myComponent.DBEndpoint = endpoint - return nil - }). - Build() + }) + +integration.ExtractInto(builder, dbEndpoint, func(o uns.Unstructured) (string, error) { + endpoint, _, err := uns.NestedString(o.Object, "status", "endpoint") + return endpoint, err +}) + +resource, err := builder.Build() ``` ## Guidance