From cca671e0bcdde0a9f67df00ad6a0b05a1d07ab31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:53:12 +0200 Subject: [PATCH 01/37] docs: scaffold Material for MkDocs site, make targets, and Pages workflow --- .github/workflows/docs.yml | 57 ++++++++++++++++++ .gitignore | 3 + .tool-versions | 1 + Makefile | 8 +++ docs/index.md | 5 ++ docs/stylesheets/extra.css | 5 ++ mkdocs.yml | 115 +++++++++++++++++++++++++++++++++++++ requirements-docs.txt | 1 + 8 files changed, 195 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/index.md create mode 100644 docs/stylesheets/extra.css create mode 100644 mkdocs.yml create mode 100644 requirements-docs.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..abb122a8 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Docs + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install docs dependencies + run: pip install -r requirements-docs.txt + + - name: Build site (strict) + run: mkdocs build --strict + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + name: Deploy to GitHub Pages + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6aed4f58..f2d6818b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ CLAUDE.md posts # Internal planning artifacts (specs/plans), keep local-only docs/superpowers/ + +# MkDocs build output +site/ diff --git a/.tool-versions b/.tool-versions index eb3b2727..c519e9b9 100644 --- a/.tool-versions +++ b/.tool-versions @@ -2,3 +2,4 @@ golang 1.25.6 golangci-lint 2.11.2 nodejs 25.1.0 kind 0.31.0 +python 3.14.4 diff --git a/Makefile b/Makefile index 667f21ed..0aa8ea5e 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,14 @@ $(PRETTIER): $(LOCALBIN) chmod +x $(PRETTIER) ; \ } +.PHONY: docs-serve +docs-serve: ## Serve the documentation site locally with live reload. + mkdocs serve + +.PHONY: docs-build +docs-build: ## Build the documentation site in strict mode. + mkdocs build --strict + .PHONY: lint ## Run all linters lint: lint-go lint-md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..26833c69 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,5 @@ +# Operator Component Framework + +A Go framework for building Kubernetes operators that stay maintainable as they grow. + +This landing page is finalized in a later step. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..23b0184b --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,5 @@ +/* Brand and spacing overrides for Material for MkDocs. */ +:root { + --md-primary-fg-color: #3f51b5; + --md-accent-fg-color: #3f51b5; +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..2992ddca --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,115 @@ +site_name: Operator Component Framework +site_description: A Go framework for building maintainable Kubernetes operators +site_url: https://sourcehawk.github.io/operator-component-framework/ +repo_url: https://github.com/sourcehawk/operator-component-framework +repo_name: sourcehawk/operator-component-framework +edit_uri: edit/main/docs/ + +# docs/superpowers/ is gitignored and never published. +exclude_docs: | + superpowers/ + +theme: + name: material + icon: + repo: fontawesome/brands/github + features: + - navigation.tabs + - navigation.sections + - navigation.indexes + - navigation.top + - navigation.tracking + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - tables + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.tabbed: + alternate_style: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - toc: + permalink: true + +plugins: + - search + +extra_css: + - stylesheets/extra.css + +nav: + - Home: index.md + - Concepts: + - Component: component.md + - Primitives Overview: primitives.md + - Custom Resources: custom-resource.md + - Primitives: + - Workloads: + - Pod: primitives/pod.md + - Deployment: primitives/deployment.md + - StatefulSet: primitives/statefulset.md + - DaemonSet: primitives/daemonset.md + - ReplicaSet: primitives/replicaset.md + - Job: primitives/job.md + - CronJob: primitives/cronjob.md + - Networking: + - Service: primitives/service.md + - Ingress: primitives/ingress.md + - NetworkPolicy: primitives/networkpolicy.md + - Config & Secrets: + - ConfigMap: primitives/configmap.md + - Secret: primitives/secret.md + - Storage: + - PersistentVolume: primitives/pv.md + - PersistentVolumeClaim: primitives/pvc.md + - RBAC: + - ServiceAccount: primitives/serviceaccount.md + - Role: primitives/role.md + - RoleBinding: primitives/rolebinding.md + - ClusterRole: primitives/clusterrole.md + - ClusterRoleBinding: primitives/clusterrolebinding.md + - Scaling & Availability: + - HorizontalPodAutoscaler: primitives/hpa.md + - PodDisruptionBudget: primitives/pdb.md + - Escape Hatch: + - Unstructured: primitives/unstructured.md + - Guides: + - Guidelines: guidelines.md + - Testing: testing.md + - Reference: + - Compatibility: compatibility.md diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..83a41987 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1 @@ +mkdocs-material==9.7.6 From 7767ebc834ff97903633f24e6b77697bdbd30e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:59:13 +0200 Subject: [PATCH 02/37] docs: add landing page, getting-started stub, and fix out-of-tree links for strict build Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/compatibility.md | 6 ++++-- docs/getting-started.md | 7 +++++++ docs/guidelines.md | 4 +++- docs/index.md | 41 +++++++++++++++++++++++++++++++++++++++-- docs/testing.md | 4 +++- mkdocs.yml | 1 + 6 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 docs/getting-started.md diff --git a/docs/compatibility.md b/docs/compatibility.md index 08d60343..15a92839 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -24,8 +24,10 @@ dependencies alone. ## How Compatibility Is Tested -The [compatibility workflow](../.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: +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, diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..0880674e --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,7 @@ +# Getting Started + +A step-by-step walkthrough that builds your first component is in progress and will land in this section. + +In the meantime, the [Component](component.md) and [Primitives](primitives.md) pages cover the core concepts, and the +[README quick start](https://github.com/sourcehawk/operator-component-framework#quick-start) shows a minimal end-to-end +example. diff --git a/docs/guidelines.md b/docs/guidelines.md index 9ae98389..db4bc7ec 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -512,7 +512,9 @@ compat mutation replaces the entire container (sets all fields, not just `Name` mutations are lost. In that case, the mutation is effectively a full override and later mutations should target the post-rename name via version gating rather than relying on ordering. -See the [mutations-and-gating example](../examples/mutations-and-gating/) for a working demonstration of these patterns. +See the +[mutations-and-gating example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating) +for a working demonstration of these patterns. ## Use Data Extraction and Guards for Resource Dependencies diff --git a/docs/index.md b/docs/index.md index 26833c69..8cc98069 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,42 @@ # Operator Component Framework -A Go framework for building Kubernetes operators that stay maintainable as they grow. +A Go framework for building Kubernetes operators that stay maintainable as they grow. It pulls reconciliation mechanics, +status reporting, and lifecycle behavior into reusable building blocks (**components** and **resource primitives**), so +your controllers stay thin and focused on construction and orchestration. -This landing page is finalized in a later step. +!!! note + + This framework is not a replacement for + [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime). It is a library you use inside + controller-runtime reconcilers (such as Kubebuilder-generated projects) to manage the layers between the reconciler + and the Kubernetes resources it manages. + +## Start here + +
+ +- :material-rocket-launch-outline: **[Getting Started](getting-started.md)** + + Build your first component step by step. + +- :material-cube-outline: **[Component](component.md)** + + Lifecycle, status model, and reconciliation phases. + +- :material-shape-outline: **[Primitives](primitives.md)** + + Typed wrappers over Kubernetes resources with builders, mutators, and feature gating. + +- :material-source-branch: **[Custom Resources](custom-resource.md)** + + Build custom resource wrappers with `pkg/generic`. + +- :material-book-open-variant: **[Guidelines](guidelines.md)** + + Patterns for structuring operators well. + +- :material-test-tube: **[Testing](testing.md)** + + Golden snapshots and version-matrix golden generation. + +
diff --git a/docs/testing.md b/docs/testing.md index 321df9c6..ecaa9fa2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -79,7 +79,9 @@ where a gate flips. Asserting one golden per version is wasteful and obscures wh `goldengen` sweeps the versions you supply, groups them by which mutations fire, and writes one golden per distinct group. -The worked example lives at [`examples/version-matrix`](../examples/version-matrix). The walkthrough below follows it. +The worked example lives at +[`examples/version-matrix`](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/version-matrix). +The walkthrough below follows it. ### Declare the matrix diff --git a/mkdocs.yml b/mkdocs.yml index 2992ddca..a4fb8ab6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ extra_css: nav: - Home: index.md + - Getting Started: getting-started.md - Concepts: - Component: component.md - Primitives Overview: primitives.md From 64b9ee35a493db4dd12cc2dd00100f46d5e3a536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:22:09 +0200 Subject: [PATCH 03/37] docs: address site preview feedback (cards, nav, duplicate TOC) - Fix grid card rendering: restore 4-space body indentation required by Python-Markdown and protect it from prettier with a prettier-ignore marker, so card descriptions render inside the card box. - Persistent left navigation on every page: drop navigation.tabs (and navigation.sections) so the home page and all pages show the full nav tree. - Remove hand-written Table of Contents sections from component, primitives, custom-resource, and guidelines pages; Material renders the page TOC in the right sidebar, so the in-body TOC was redundant. - Fix a stale in-page anchor link in component.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/component.md | 36 +----------------------------------- docs/custom-resource.md | 28 ---------------------------- docs/guidelines.md | 18 ------------------ docs/index.md | 13 +++++++------ docs/primitives.md | 23 ----------------------- mkdocs.yml | 3 +-- 6 files changed, 9 insertions(+), 112 deletions(-) diff --git a/docs/component.md b/docs/component.md index c422af1a..0289a005 100644 --- a/docs/component.md +++ b/docs/component.md @@ -6,40 +6,6 @@ related resources into **Components**. A Component acts as a single behavioral unit: it reconciles multiple resources, manages their shared lifecycle, and reports their aggregate health through one condition on the owner CRD. -## Table of Contents - -- [Building a Component](#building-a-component) - - [Resource Registration Options](#resource-registration-options) - - [Conditional and Optional Resources](#conditional-and-optional-resources) -- [Component Feature Gates](#component-feature-gates) -- [Prerequisites](#prerequisites) - - [Registering Prerequisites](#registering-prerequisites) - - [Prerequisite Behavior](#prerequisite-behavior) - - [Status Reporting](#status-reporting) -- [Reconciliation Lifecycle](#reconciliation-lifecycle) -- [Previewing Desired State](#previewing-desired-state) -- [Cluster-Scoped Resources](#cluster-scoped-resources) -- [Status Model](#status-model) - - [Alive Resources](#alive-resources-alive-interface) - - [Completable Resources](#completable-resources-completable-interface) - - [Operational Resources](#operational-resources-operational-interface) - - [Static Resources](#static-resources-no-interface) - - [Grace States](#grace-states) - - [Suspension States](#suspension-states) - - [Guard State](#guard-state) - - [Prerequisite State](#prerequisite-state) - - [Feature Gate State](#feature-gate-state) - - [Condition Priority](#condition-priority) -- [Grace Period](#grace-period) -- [Suspension Lifecycle](#suspension-lifecycle) -- [ReconcileContext](#reconcilecontext) -- [Persisting Status with FlushStatus](#persisting-status-with-flushstatus) -- [Guards](#guards) - - [Registering a Guard](#registering-a-guard) - - [Guard Behavior](#guard-behavior) - - [Status Reporting](#status-reporting-1) -- [Best Practices](#best-practices) - ## Building a Component Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle @@ -80,7 +46,7 @@ control how the component interacts with the resource: A read-only resource is not owned by the component, so it is never deleted. `ReadOnly()` is mutually exclusive with `Delete()`, `DeleteWhen()`, and `GatedBy()`; combining them is a build error. To conditionally include a read-only -resource, use [`IncludeWhen`](#includewhen), which omits the resource without deleting it. +resource, use [`IncludeWhen`](#includewhen-vs-gatedby-two-different-axes), which omits the resource without deleting it. ### Conditional and Optional Resources diff --git a/docs/custom-resource.md b/docs/custom-resource.md index fa53d666..331629fd 100644 --- a/docs/custom-resource.md +++ b/docs/custom-resource.md @@ -7,34 +7,6 @@ generics with type-specific logic. --- -## Table of Contents - -- [When to Implement a Custom Resource](#when-to-implement-a-custom-resource) -- [Architecture](#architecture) -- [Choosing a Resource Category](#choosing-a-resource-category) -- [Step-by-Step Implementation](#step-by-step-implementation) - - [1. Define the Mutation Type Alias](#1-define-the-mutation-type-alias) - - [2. Implement the Mutator](#2-implement-the-mutator) - - [The Plan-and-Apply Pattern](#the-plan-and-apply-pattern) - - [Mutator Design Guidelines](#mutator-design-guidelines) - - [3. Implement Status Handlers](#3-implement-status-handlers) - - [Workload Handlers](#workload-handlers) - - [Convergence and Grace Status Consistency](#convergence-and-grace-status-consistency) - - [Status Constants Reference](#status-constants-reference) - - [4. Implement the Builder](#4-implement-the-builder) - - [Builder Pattern Guidelines](#builder-pattern-guidelines) - - [5. Implement the Resource](#5-implement-the-resource) - - [6. Define Feature Mutations](#6-define-feature-mutations) - - [7. Register with a Component](#7-register-with-a-component) -- [Cluster-Scoped Resources](#cluster-scoped-resources) -- [Category-Specific Notes](#category-specific-notes) - - [Static Resources](#static-resources) - - [Task Resources](#task-resources) - - [Integration Resources](#integration-resources) -- [Reference](#reference) - ---- - ## When to Implement a Custom Resource The built-in primitives cover common Kubernetes types (Deployments, ConfigMaps, Services, etc.) and are highly diff --git a/docs/guidelines.md b/docs/guidelines.md index db4bc7ec..86362db8 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -3,24 +3,6 @@ Recommendations for structuring operators built with the framework. These are not hard rules. They reflect patterns that are effective and pitfalls that are easy to walk into. -## Table of Contents - -- [Represent Desired State in the Baseline Object](#represent-desired-state-in-the-baseline-object) -- [One Component Per Logical Condition](#one-component-per-logical-condition) -- [Keep Controllers Thin](#keep-controllers-thin) -- [Resource Registration Order Is Execution Order](#resource-registration-order-is-execution-order) -- [Mutation Ordering and Container Name Dependencies](#mutation-ordering-and-container-name-dependencies) -- [Use Data Extraction and Guards for Resource Dependencies](#use-data-extraction-and-guards-for-resource-dependencies) - - [Prefer stable values for guard conditions](#prefer-stable-values-for-guard-conditions) -- [Use Prerequisites for Cross-Component Dependencies](#use-prerequisites-for-cross-component-dependencies) -- [Use Component Feature Gates for Optional Components](#use-component-feature-gates-for-optional-components) -- [Mutations Describe Intent, Not Observation](#mutations-describe-intent-not-observation) -- [Understand Participation Modes](#understand-participation-modes) -- [Use Feature Gating for Conditional Resources](#use-feature-gating-for-conditional-resources) -- [Grace Periods Are Convergence Time](#grace-periods-are-convergence-time) -- [Handle Cluster-Scoped Resources Explicitly](#handle-cluster-scoped-resources-explicitly) -- [Name Conditions for the Audience Reading Them](#name-conditions-for-the-audience-reading-them) - ## Represent Desired State in the Baseline Object The core object passed to a primitive builder should represent the latest desired state of the resource. When the diff --git a/docs/index.md b/docs/index.md index 8cc98069..1b15b383 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,28 +15,29 @@ your controllers stay thin and focused on construction and orchestration.
+ - :material-rocket-launch-outline: **[Getting Started](getting-started.md)** - Build your first component step by step. + Build your first component step by step. - :material-cube-outline: **[Component](component.md)** - Lifecycle, status model, and reconciliation phases. + Lifecycle, status model, and reconciliation phases. - :material-shape-outline: **[Primitives](primitives.md)** - Typed wrappers over Kubernetes resources with builders, mutators, and feature gating. + Typed wrappers over Kubernetes resources with builders, mutators, and feature gating. - :material-source-branch: **[Custom Resources](custom-resource.md)** - Build custom resource wrappers with `pkg/generic`. + Build custom resource wrappers with `pkg/generic`. - :material-book-open-variant: **[Guidelines](guidelines.md)** - Patterns for structuring operators well. + Patterns for structuring operators well. - :material-test-tube: **[Testing](testing.md)** - Golden snapshots and version-matrix golden generation. + Golden snapshots and version-matrix golden generation.
diff --git a/docs/primitives.md b/docs/primitives.md index 52fd50ba..75b1e7b7 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -4,29 +4,6 @@ The `primitives` package provides reusable, type-safe wrappers for individual Ku the [Component layer](component.md) and raw Kubernetes resources. They handle the complexities of state synchronization, mutation, and lifecycle management so operator authors don't have to. -## Table of Contents - -- [What a Primitive Is](#what-a-primitive-is) -- [Primitive Categories](#primitive-categories) - - [Static](#static) - - [Workload](#workload) - - [Task](#task) - - [Integration](#integration) -- [Cluster-Scoped Primitives](#cluster-scoped-primitives) -- [Lifecycle Interfaces](#lifecycle-interfaces) -- [Server-Side Apply](#server-side-apply) -- [Mutation System](#mutation-system) -- [Mutation Editors](#mutation-editors) -- [Container Selectors](#container-selectors) -- [Built-in Primitives](#built-in-primitives) -- [Usage Examples](#usage-examples) - - [Creating a primitive](#creating-a-primitive) - - [Adding a mutation](#adding-a-mutation) - - [Targeting multiple containers](#targeting-multiple-containers) - - [Adding a guard](#adding-a-guard) -- [Unstructured Primitives](#unstructured-primitives) -- [Implementing a Custom Resource](#implementing-a-custom-resource) - ## What a Primitive Is A primitive wraps a specific Kubernetes kind (e.g., `Deployment`, `ConfigMap`) and encapsulates: diff --git a/mkdocs.yml b/mkdocs.yml index a4fb8ab6..4ff0a180 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,11 +14,10 @@ theme: icon: repo: fontawesome/brands/github features: - - navigation.tabs - - navigation.sections - navigation.indexes - navigation.top - navigation.tracking + - navigation.footer - toc.follow - search.suggest - search.highlight From a882bd4c5899384d465fde6e74c35a9ea619f959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:04:05 +0200 Subject: [PATCH 04/37] docs: rewrite concept pages to writing-docs standard Rewrite Primitives Overview as the canonical hub for cross-cutting concepts (mutation system, boolean/version gating, editors, container selectors, server-side apply, workload-kind-agnostic mutations, lifecycle interface to status-value mapping), with category decision-tree and plan-and-apply Mermaid diagrams. Rewrite Component: rebuild the condition-priority table from source (all reasons including the failing reasons and FeatureGateError), correct the Auxiliary() guidance and reconciliation phase count, add reconciliation-lifecycle and status-model diagrams, document Previewable and MutationInspector, and move generic best practices to the Guidelines page. Rewrite Custom Resources: fix the example resource to implement Preview() (concepts.Previewable) and delegate MutationInspector, correct operational status string values, document the Static builder WithGuard option and the identity format contract, add an Integration builder example, and add a step index. Lean pages link shared concepts to the hub. Add Go API external link to the nav. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/component.md | 681 +++++++++++++++++++--------------------- docs/custom-resource.md | 677 +++++++++++++++++++++++---------------- docs/primitives.md | 576 ++++++++++++++++++++------------- mkdocs.yml | 1 + 4 files changed, 1092 insertions(+), 843 deletions(-) diff --git a/docs/component.md b/docs/component.md index 0289a005..4ab9e554 100644 --- a/docs/component.md +++ b/docs/component.md @@ -1,10 +1,22 @@ -# Component System +# Component -The `component` package provides a structured way to manage logical features in a Kubernetes operator by grouping -related resources into **Components**. +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 acts as a single behavioral unit: it reconciles multiple resources, manages their shared lifecycle, and -reports their aggregate health through one condition on the owner CRD. +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. + +```text +Controller + └─ Component one condition on the owner + └─ Resource Primitive Deployment, ConfigMap, Service, ... + └─ Kubernetes 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 @@ -13,13 +25,14 @@ flags, then produces an immutable `Component` ready for reconciliation. ```go comp, err := component.NewComponentBuilder(). - WithName("web-interface"). - WithConditionType("WebInterfaceReady"). - WithFeatureGate(webFeature). // optional: disable to remove all resources - WithPrerequisite(component.DependsOn("DatabaseReady")). // optional: wait for another component - WithResource(deployment). - WithResource(configMap, component.ReadOnly()). - WithResource(oldService, component.Delete()). + 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() @@ -28,73 +41,74 @@ if err != nil { } ``` -### Resource Registration Options +### 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: - -| Option | Behavior | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| (none) | **Managed**: created or updated; 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.Auxiliary()` | The resource's health does not contribute to the component condition (a blocked guard still does) | -| `component.SuppressGraceInconsistencyWarning()` | Suppresses the grace/convergence inconsistency warning | -| `component.ReadOnly(), component.BlockOnAbsence()` | **Read-only with watch-driven retry**: NotFound records a blocked status and short-circuits the remaining resources | -| `component.ReadOnly(), component.IgnoreIfAbsent()` | **Optional read-only**: NotFound is silently ignored; last-known state preserved | +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.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()`, and `GatedBy()`; combining them is a build error. To conditionally include a read-only -resource, use [`IncludeWhen`](#includewhen-vs-gatedby-two-different-axes), which omits the resource without deleting it. +`Delete()`, `DeleteWhen()`, and `GatedBy()`; 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. -### Conditional and Optional Resources - -Pass functional options directly on the `WithResource` call to express feature gating, auxiliary participation, or any -combination: +Options compose. Gate a resource and exclude it from health aggregation in one call: ```go component.NewComponentBuilder(). WithName("api"). WithConditionType("ApiReady"). WithResource(apiDeployment). - WithResource(exporter, component.GatedBy(tracingGate), component.Auxiliary()). + WithResource(metricsExporter, component.GatedBy(tracingGate), component.Auxiliary()). Build() ``` -When `tracingGate` is disabled, the exporter is deleted from the cluster. When enabled, it is managed but does not block -the component from becoming Ready. +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: two different axes +### IncludeWhen vs. GatedBy -These two look similar but answer different questions, and picking the wrong one either deletes a resource you do not -own or fails to clean up one you do: +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 (a Deployment, a ConfigMap) exist - for some states and be removed for others. -- **`IncludeWhen` — conditionally _include_ a resource, never deleting it.** When the condition is false the resource is +- **`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. +`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 LicenseSecretRef when it is non-nil. -builder.IncludeWhen(spec.LicenseSecretRef != nil, func() component.Resource { - r := spec.LicenseSecretRef - return common.SecretRef(r.Name, r.Namespace, hash) +// 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**: passing `include = false` -leaves an already-present resource in place, unmanaged, rather than removing it from the cluster the way `GatedBy` or -`DeleteWhen` would. +A secondary use is migrating a resource from tracked to untracked without deleting it: passing `include = false` leaves +an already-present resource in place, unmanaged, rather than removing it the way `GatedBy` or `DeleteWhen` would. -## Component Feature Gates +## 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 @@ -112,7 +126,7 @@ comp, err := component.NewComponentBuilder(). ``` 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 are deleted), not suspended. +suspended, the component is treated as disabled (resources deleted), not suspended. The condition when the gate is disabled: @@ -126,6 +140,12 @@ 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 @@ -134,29 +154,29 @@ has not yet proceeded past initialization. The barrier remains active while the `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 expressing startup dependencies between components. If a dependency later becomes -unhealthy, the dependent component continues to reconcile its own resources. Prerequisites answer the question "can this -component be created?", not "should this component keep running?". +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 +### Registering prerequisites -Prerequisites are registered on the component builder using `WithPrerequisite`. Multiple prerequisites can be -registered; all must be satisfied before the component proceeds. +Prerequisites are registered with `WithPrerequisite`. Multiple may be registered; all must be satisfied before the +component proceeds. ```go comp, err := component.NewComponentBuilder(). - WithName("api-server"). - WithConditionType("ApiServerReady"). - WithPrerequisite(component.DependsOn("DatabaseReady")). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithPrerequisite(component.DependsOn("BackendReady")). WithPrerequisite(component.DependsOn("CacheReady")). - WithResource(apiDeployment). - WithResource(apiService). + WithResource(frontendDeployment). + WithResource(frontendService). Suspend(owner.Spec.Suspended). Build() ``` -The built-in `DependsOn` helper checks whether a named condition on the owner object has `Status: True`. The owner is -read from the `ReconcileContext` passed to `Check`, so no cluster reads are performed. +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: @@ -166,106 +186,96 @@ type Prerequisite interface { } ``` -### Prerequisite Behavior +### Prerequisite behavior -- Prerequisites are evaluated before any resources are reconciled or suspended. -- The barrier is considered active when the component's condition reason is `Unknown`, `PrerequisiteNotMet`, `Disabled`, - or `FeatureGateError`. Any other reason means the component has proceeded past initialization and the barrier is +- 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`. -### Status Reporting - A blocked prerequisite produces a condition like: ```yaml -type: ApiServerReady +type: FrontendReady status: "False" reason: PrerequisiteNotMet message: - 'Prerequisite not met: waiting for condition "DatabaseReady" to become True (currently False: Database is still - creating resources)' + 'Prerequisite not met: waiting for condition "BackendReady" to become True (currently False: Backend is still creating + resources)' ``` ## Reconciliation Lifecycle -`comp.Reconcile(ctx, recCtx)` runs a multi-phase process on every call: - -**Phase 1: Feature gate check.** If a feature gate is set and disabled, all resources managed by the component are -deleted and the condition is set to `True/Disabled`. No further processing occurs. - -**Phase 2: Prerequisite check.** If prerequisites are registered and the initialization barrier has not yet been passed -(condition reason is `Unknown`, `PrerequisiteNotMet`, `Disabled`, or `FeatureGateError`), all prerequisites are -evaluated. If any prerequisite is not met, the condition is set to `False/PrerequisiteNotMet` and no resources are -reconciled or suspended. - -**Phase 3: Suspension check.** If the component is marked suspended, it calls `Suspend()` on all managed resources that -support suspension (create/update resources, not read-only ones), updates the condition, then processes any pending -deletions and returns. The remaining phases are skipped. - -**Phase 4: Resource reconciliation.** All non-delete resources are processed sequentially in registration order, -regardless of whether they are managed or read-only. For each resource: - -1. If the resource has a [guard](#guards), the guard is evaluated first. If blocked, the resource and all subsequent - resources are skipped. -2. The resource is either applied to the cluster (managed) or fetched from it (read-only). Managed resources use - Server-Side Apply and get a controller owner reference pointing to the owner CRD, unless the resource is - cluster-scoped and the owner is namespace-scoped (see [Cluster-Scoped Resources](#cluster-scoped-resources)). -3. For read-only resources that implement `ObservationRecorder`, the framework records the fetched object back onto the - resource so that subsequent inspection observes the live cluster state. Resources built from `generic.BaseResource` - implement this automatically. -4. If the resource implements `DataExtractable`, its data extractors run immediately. This makes extracted data - available to subsequent resources' guards and mutations within the same reconciliation cycle. - -This means a read-only resource registered before a managed resource can extract data that feeds into the managed -resource's guard or mutations. - -**Phase 5: Status aggregation and condition update.** The health of each resource is collected, the grace period is -consulted, and a single aggregate condition is written to the owner object's conditions **in memory**. `Reconcile` never -calls the Kubernetes API to persist status; the controller does that in a single write at the end of its reconcile loop. -See [Persisting Status with FlushStatus](#persisting-status-with-flushstatus). - -**Phase 6: Resource deletion.** Resources registered for deletion are removed from the cluster. - -## Previewing Desired State +`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]) +``` -`Component.Preview() ([]client.Object, error)` renders the desired state of every managed resource registered on the -component, in registration order, without contacting the cluster. Read-only resources (fetched, not applied) and delete -resources (removal markers) are excluded. +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)). -`Preview` does not evaluate guards. Reconciliation stops at the first resource whose guard is `Blocked` and skips that -resource and all later ones, but a guard's outcome typically depends on cluster state and data extracted from earlier -resources, neither of which is available in a cluster-free render. `Preview` therefore reports the full desired shape of -every managed resource, including ones a given reconcile might skip behind a blocked guard. This keeps the snapshot -deterministic and focused on baseline construction, mutation wiring, and registration order. +### Previewing desired state -Each managed resource must implement `concepts.Previewable`. All built-in primitives satisfy this through -`generic.BaseResource`. `Preview` returns an error if any managed resource does not implement `concepts.Previewable` or -if rendering a resource fails. +`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. -`Component.Resource(identity string) (Resource, bool)` looks up a registered resource by its `Identity()` string. For -namespaced resources the identity is `///` (for example -`apps/v1/Deployment/default/web`); cluster-scoped resources omit the namespace segment (for example -`v1/PersistentVolume/data` or `rbac.authorization.k8s.io/v1/ClusterRole/viewer`). The lookup covers all registered -resources: managed, read-only, and delete resources. +`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. -`Reconcile` remains the only path that performs cluster IO. `Preview` is the natural input for whole-component golden -snapshots via `golden.AssertComponentYAML`. +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 -comp, err := buildWebComponent(owner) -if err != nil { - return err -} - objs, err := comp.Preview() if err != nil { return err } - for _, obj := range objs { fmt.Printf("%s/%s\n", obj.GetNamespace(), obj.GetName()) } @@ -277,127 +287,115 @@ If you need the concrete Kubernetes type rather than `client.Object`, type-asser dep, ok := objs[0].(*appsv1.Deployment) ``` -## Cluster-Scoped Resources - -When a component manages cluster-scoped resources (e.g., `ClusterRole`, `PersistentVolume`) and the owner CRD is -namespace-scoped, the framework **automatically skips** setting a controller owner reference on those resources. This is -a Kubernetes API constraint: a namespace-scoped object cannot own a cluster-scoped object. - -The scope of both the owner and the resource is determined at reconcile time using the cluster's REST mapper. No -configuration is needed; the framework detects the incompatibility and logs an info-level message. - -**Garbage collection caveat:** Without an owner reference, cluster-scoped resources are **not** automatically deleted -when the owner is removed. To ensure cleanup, either: - -- Register the resource with `component.Delete()` so it is removed during reconciliation when no longer needed. -- Use a finalizer on the owner CRD to clean 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 their scope. - -## Status Model - -The status values a component reports depend on which lifecycle interfaces its resources implement. The component -aggregates across all registered resources and surfaces the most critical state. - -### Alive Resources (`Alive` interface) - -Reported by long-running workloads (Deployments, StatefulSets, DaemonSets): - -| State | Meaning | -| ---------- | -------------------------------------------------------- | -| `Healthy` | The resource has reached its desired state | -| `Creating` | The resource is being provisioned for the first time | -| `Updating` | The resource is being modified with new configuration | -| `Scaling` | The resource is changing its replica count | -| `Failing` | The resource is failing to converge to its desired state | - -### Completable Resources (`Completable` interface) - -Reported by run-to-completion resources (Jobs, tasks): +`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`). -| State | Meaning | -| ------------- | ----------------------------------- | -| `Completed` | The resource finished successfully | -| `TaskRunning` | The resource is currently executing | -| `TaskPending` | The resource is waiting to start | -| `TaskFailing` | The resource finished with an error | +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. -### Operational Resources (`Operational` interface) +### Cluster-scoped resources -Reported by integration resources whose readiness depends on external systems (Services, Ingresses, Gateways, CronJobs): +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. -| State | Meaning | -| ------------------ | ------------------------------------------------- | -| `Operational` | The resource is fully operational | -| `OperationPending` | The resource is waiting on an external dependency | -| `OperationFailing` | The resource failed to reach an operational state | +!!! warning -### Static Resources (no interface) + 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. -Resources that implement none of the above interfaces are considered ready as long as they exist in the cluster. If a -static resource has a [guard](#guards), it can report `Blocked` when the guard precondition is not met. +If the owner CRD is itself cluster-scoped, owner references are set normally on all resources regardless of scope. -### Grace States - -When a component has a grace period configured and a `Graceful` resource has not reached its target state within that -period, the `Graceful` interface determines the post-expiry severity: - -| State | Meaning | -| ---------- | ---------------------------------------------------------------------------------- | -| `Healthy` | The resource is healthy (grace period expired without issue) | -| `Degraded` | The resource is partially functional or convergence is taking longer than expected | -| `Down` | The resource is completely non-functional | - -### Suspension States - -Reported during intentional deactivation: - -| State | Meaning | -| ------------------- | ------------------------------------------------------ | -| `PendingSuspension` | Suspension is acknowledged but has not started | -| `Suspending` | Resources are actively being scaled down or cleaned up | -| `Suspended` | All resources have reached their suspended state | - -### Guard State - -| State | Meaning | -| --------- | ---------------------------------------------------------------------------- | -| `Blocked` | A resource's guard precondition is not met; it and subsequent resources wait | - -See [Guards](#guards) for details. - -### Prerequisite State - -| State | Meaning | -| -------------------- | ---------------------------------------------------------------------------------- | -| `PrerequisiteNotMet` | A component-level prerequisite is not satisfied; no resources have been reconciled | - -See [Prerequisites](#prerequisites) for details. - -### Feature Gate State - -| State | Meaning | -| ---------- | --------------------------------------------------------------- | -| `Disabled` | The component's feature gate is disabled; all resources deleted | - -See [Component Feature Gates](#component-feature-gates) for details. - -### Condition Priority +## Status Model -When aggregating across multiple resources, the most critical state wins: +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 +``` -1. `Error` / `Down` / `Degraded`: something is wrong -2. Suspension states: the component is intentionally inactive -3. `Disabled`: the component is intentionally removed by a feature gate -4. `Blocked` / `PrerequisiteNotMet`: a precondition is not met -5. Converging states (`Creating`, `Updating`, `Scaling`, `TaskRunning`, `TaskPending`, `OperationPending`): the - component is progressing -6. `Healthy` / `Completed` / `Operational`: all resources are in their target state +### 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 transitioning to `Degraded` or `Down`. +before escalating to `Degraded` or `Down`. ```go component.NewComponentBuilder(). @@ -406,28 +404,27 @@ component.NewComponentBuilder(). ``` 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`, the framework escalates to `Degraded` or `Down` based on resource health. - -This prevents spurious failure alerts during normal operations like rolling updates. +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 Lifecycle +## Suspension -Suspension allows a component to be intentionally deactivated without deleting its configuration. When `Suspend(true)` -is set on the builder: +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`. -Resources that do not yet exist in the cluster are created in their suspended state (with suspension mutations already -applied). For example, a Deployment is created with zero replicas. This ensures the resource is immediately available -when suspension ends. +The progression reports `PendingSuspension`, then `Suspending`, then `Suspended` (all with condition status `True`). -Resources with `DeleteOnSuspend` enabled are **not** created if they are already absent. Their absence is treated as -already suspended. This avoids a create→delete churn loop on every reconcile while the component remains suspended. - -Resources that are not `Suspendable` are left in place. +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 @@ -445,22 +442,20 @@ recCtx := component.ReconcileContext{ err = comp.Reconcile(ctx, recCtx) ``` -Dependencies are passed explicitly so components remain 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. The recorder implementation is provided by -[go-crd-condition-metrics](https://github.com/sourcehawk/go-crd-condition-metrics). Leave the field `nil` to opt out of -metric recording. +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 is responsible for writing -those conditions to the Kubernetes API by calling `component.FlushStatus` once per reconcile, typically from a deferred -call so that conditions set on error paths are still persisted: +`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 *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - owner := &v1alpha1.MyApp{} +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) } @@ -478,7 +473,7 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ } }() - comp, err := buildMyComponent(owner) + comp, err := buildFrontendComponent(owner) if err != nil { return reconcile.Result{}, err } @@ -489,62 +484,36 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ `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 the update succeeds, `FlushStatus` records metrics for every condition on the owner. If `rec.Metrics` is nil, -metric recording is skipped. +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 allows a controller with several components (see [Keep Controllers Thin](./guidelines.md) and -[One Component Per Logical Condition](./guidelines.md)) to stage several conditions during one reconcile and persist -them all in a single write. Persisting after every component would race the components' writes against each other and -produce 409 conflicts. +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 allow resources within a component to express runtime dependencies on each other. A guard is a precondition -function registered on a resource that is evaluated before the resource is applied. If the guard returns `Blocked`, the -resource and all resources registered after it are skipped for that reconciliation cycle. +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 runs and populates a shared variable, and Resource B's guard checks that variable before allowing B to -proceed. +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 +### Registering a guard -Guards are registered on the resource builder using `WithGuard`. The guard function receives a copy of the resource -object and returns a `GuardStatusWithReason`. - -The following example shows the complete pattern. A cloud provider role resource extracts its ARN after being applied. A -bucket resource uses that ARN in its spec and guards against being applied before the ARN is available: +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 (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { - // ...fetch owner and build recCtx... - - defer func() { - if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil { - err = flushErr - } - }() - - // roleARN is scoped to this reconcile call. The role resource's data extractor - // populates it after the role is applied. Because extraction runs per-resource - // (not after all resources), roleARN is set before the bucket's guard evaluates. - var roleARN string - - comp, err := buildCloudComponent(owner, &roleARN) - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, comp.Reconcile(ctx, recCtx) -} - -func buildCloudComponent(owner *v1alpha1.MyApp, roleARN *string) (*component.Component, error) { - // First resource: the cloud provider role. - // After it is applied, the data extractor reads the ARN from the object. - roleRes, err := static.NewBuilder(newCloudRole(owner)). +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 { - *roleARN = obj.Object["status"].(map[string]any)["arn"].(string) + *endpoint = obj.Object["data"].(map[string]any)["endpoint"].(string) return nil }). Build() @@ -552,27 +521,24 @@ func buildCloudComponent(owner *v1alpha1.MyApp, roleARN *string) (*component.Com return nil, err } - // Second resource: the cloud provider bucket. - // The role's data extractor populates *roleARN earlier in this same reconcile - // cycle, which causes the guard to clear. The mutation then runs lazily at - // Mutate() time and injects the now-populated *roleARN into the bucket spec. - bucketRes, err := static.NewBuilder(newCloudBucket(owner)). + // 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 *roleARN == "" { + if *endpoint == "" { return concepts.GuardStatusWithReason{ Status: concepts.GuardStatusBlocked, - Reason: "waiting for cloud provider role ARN", + Reason: "waiting for backend endpoint", }, nil } - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusUnblocked, - }, nil + return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil }). WithMutation(unstruct.Mutation{ - Name: "set-role-arn", + Name: "set-endpoint", Mutate: func(m *unstruct.Mutator) error { m.EditContent(func(e *editors.UnstructuredContentEditor) error { - return e.SetNestedString(*roleARN, "spec", "roleARN") + return e.SetNestedString(*endpoint, "spec", "endpoint") }) return nil }, @@ -582,55 +548,54 @@ func buildCloudComponent(owner *v1alpha1.MyApp, roleARN *string) (*component.Com return nil, err } - // Registration order matters: the role must be registered before the bucket. + // Registration order matters: the config source must be registered before the consumer. return component.NewComponentBuilder(). - WithName("cloud-resources"). - WithConditionType("CloudResourcesReady"). - WithResource(roleRes). - WithResource(bucketRes). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(configRes). + WithResource(consumerRes). Build() } ``` -The guard function receives the resource's object but is not required to use it. Guards that only check external state -(closure variables populated by prior extractors) can ignore the parameter. +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 +### Guard behavior -- Guards are evaluated in resource registration order, before each resource is applied. +- 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 the resource's participation mode. All resources after it are skipped entirely. This override exists - because a blocked guard halts the entire pipeline, and subsequent required resources would otherwise be silently - absent from health aggregation. -- On the next reconciliation cycle, if the guard clears (returns `Unblocked`), the resource is applied normally. + 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 component condition to `Error`. - -### Status Reporting +- 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: WebInterfaceReady +type: BackendReady status: "False" reason: Blocked -message: "waiting for cloud provider role ARN" +message: "waiting for backend endpoint" ``` -The `Blocked` status is not sticky -- it is self-reinforcing because the guard re-evaluates on every reconcile. When the -guard clears, the status immediately transitions to the next applicable state (e.g., `Creating`). +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`). -## Best Practices +!!! note -**Keep controllers thin.** The controller's job is to fetch the owner CRD, decide which components should exist, and -call `Reconcile` on each. Resource-level logic belongs in the component and its primitives. + `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. -**One component per user-visible feature.** If you want a `WebInterfaceReady` and a `DatabaseReady` condition on your -CRD, those are two separate components. +## Component-Specific Guidance -**Group by lifecycle.** Resources that must live and die together belong in the same component. If they have independent -lifecycles, split them. +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 `ParticipationModeAuxiliary` for non-critical resources.** A metrics exporter sidecar should not block your -primary component from becoming `Ready`. All resource types default to `ParticipationModeRequired`, so set -`ParticipationModeAuxiliary` explicitly when a resource's health should not gate the component condition. +**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/docs/custom-resource.md b/docs/custom-resource.md index 331629fd..f7f70e7e 100644 --- a/docs/custom-resource.md +++ b/docs/custom-resource.md @@ -1,86 +1,100 @@ -# Custom Resource Implementation Guide +# Custom Resources -This guide explains how to implement custom resource wrappers for Kubernetes objects not covered by the -[built-in primitives](primitives.md). The framework provides a set of generic building blocks in `pkg/generic` that -handle reconciliation mechanics, mutation sequencing, suspension, and data extraction. Your custom resource wraps these -generics with type-specific logic. +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. -## When to Implement a Custom Resource +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. -The built-in primitives cover common Kubernetes types (Deployments, ConfigMaps, Services, etc.) and are highly -customizable: you can override status handlers, suspension logic, mutation behavior, and data extractors without leaving -the primitive API. Implement a custom resource when the Kubernetes type you need to manage has no corresponding built-in -primitive: +!!! note "If your CRD has no typed Go struct" -- A **custom CRD** defined by your project or a third-party operator -- A **standard Kubernetes type** not yet covered by the built-in set + 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. --- -## Architecture - -A custom resource implementation consists of three pieces: - -``` -Builder → configures and validates the resource - └─ Resource → thin wrapper delegating to a generic resource - └─ Mutator → records and applies mutations to the Kubernetes object +## 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"] ``` -Each piece wraps a corresponding generic type from `pkg/generic`: +| 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` | -| Your Type | Wraps | -| ---------- | ------------------------------------------------- | -| `Builder` | `generic.WorkloadBuilder[T, *Mutator]` (or other) | -| `Resource` | `generic.WorkloadResource[T, *Mutator]` | -| `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. --- -## Choosing a Resource Category +## 1. Choose a resource category -The framework defines four resource categories. Each maps to a generic resource type with different lifecycle -interfaces: +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 | +| 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) | +| **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | -Pick the category that matches your CRD's lifecycle. A CRD that manages a long-running process and needs health tracking -is a **Workload**. A CRD that represents static configuration is **Static**. The rest of this guide uses Workload as the -primary example; the pattern is identical for other categories with fewer handlers to implement. +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). -For a detailed description of each lifecycle interface and its status values, see [Resource Primitives](primitives.md). +The rest of the guide uses Workload as the primary example. The pattern is identical for the other categories, with +fewer handlers to implement. --- -## Step-by-Step Implementation +## 2. Define the mutation type alias -The following sections walk through implementing a custom resource for a hypothetical `GameServer` CRD -(`gameservers.example.io/v1`). This CRD manages a long-running game server process, making it a workload resource. - -### 1. Define the Mutation Type Alias - -Create a type alias for `feature.Mutation` parameterized on your mutator type. This gives callers a clean name to use -when defining feature mutations. +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 gameserver +package messagequeue import "github.com/sourcehawk/operator-component-framework/pkg/feature" -// Mutation defines a feature-gated mutation applied to a GameServer resource. +// Mutation defines a feature-gated mutation applied to a MessageQueue resource. type Mutation = feature.Mutation[*Mutator] ``` -### 2. Implement the Mutator +--- + +## 3. Implement the mutator -The mutator is responsible for recording mutation intent and applying it in a single controlled pass. It must implement +The mutator records mutation intent and applies it in a single controlled pass. It must implement `generic.FeatureMutator`: ```go @@ -90,22 +104,17 @@ type FeatureMutator interface { } ``` -`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. - -#### The Plan-and-Apply Pattern +`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. -Mutator methods **record intent** rather than modifying the object directly. The framework calls `Apply()` once after -all mutations have been recorded. This pattern ensures: +### Plan and apply -- Mutations are applied in a single controlled pass -- Feature boundaries are preserved via `NextFeature()` -- Multiple mutations targeting the same fields resolve predictably - -Here is a mutator for the `GameServer` CRD: +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 gameserver +package messagequeue import ( examplev1 "example.io/api/v1" @@ -113,25 +122,26 @@ import ( // featurePlan groups all mutation operations recorded by a single feature. type featurePlan struct { - replicaOps []func(*examplev1.GameServerSpec) - configOps []func(*examplev1.GameServerSpec) + replicaOps []func(*examplev1.MessageQueueSpec) + configOps []func(*examplev1.MessageQueueSpec) } -// Mutator records mutation intent for a GameServer and applies changes in one pass. +// 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.GameServer + current *examplev1.MessageQueue plans []featurePlan active *featurePlan } -// NewMutator creates a new Mutator for the given GameServer. +// 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.GameServer) *Mutator { +func NewMutator(current *examplev1.MessageQueue) *Mutator { m := &Mutator{current: current} m.NextFeature() return m @@ -147,21 +157,21 @@ func (m *Mutator) NextFeature() { m.active = &m.plans[len(m.plans)-1] } -// SetMaxPlayers records intent to set the maximum player count. -func (m *Mutator) SetMaxPlayers(count int32) { - m.active.configOps = append(m.active.configOps, func(spec *examplev1.GameServerSpec) { - spec.MaxPlayers = count +// 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.GameServerSpec) { + m.active.replicaOps = append(m.active.replicaOps, func(spec *examplev1.MessageQueueSpec) { spec.Replicas = &replicas }) } -// Apply executes all recorded mutations against the GameServer. +// 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 { @@ -178,30 +188,41 @@ func (m *Mutator) Apply() error { } ``` -#### Mutator Design Guidelines +!!! 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 -- **Record, don't mutate.** Methods like `SetMaxPlayers` append to the active feature plan. They do not touch `current` - directly. -- **Scope per feature.** `NextFeature()` creates a new plan scope. The framework calls it between each registered - mutation so that each feature's operations are grouped and applied in registration order. `Apply()` iterates over - plans sequentially, giving each feature a consistent view of the object as modified by all previous features. -- **Keep it typed.** Expose domain-specific methods (`SetMaxPlayers`, `SetReplicas`) rather than generic ones. This - makes feature mutations self-documenting and prevents callers from bypassing the plan-and-apply sequence. +Status handlers translate your CRD's runtime state into framework status types. Which handlers you need depends on the +category. -### 3. Implement Status Handlers +### Required versus optional handlers -Status handlers translate your CRD's runtime state into framework status types. Which handlers you need depends on your -resource category. +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: -#### Workload Handlers +- 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`. -A workload resource requires a convergence status handler. `Build()` returns an error if it is not set. All other -handlers (grace, suspension status, suspension mutation, delete-on-suspend) default to safe no-ops at the generic layer: -grace defaults to Healthy, suspension status to Suspended, suspension mutation is a no-op, and delete-on-suspend returns -false. Register custom handlers only when your CRD needs domain-specific behavior: +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 gameserver +package messagequeue import ( "fmt" @@ -210,16 +231,24 @@ import ( examplev1 "example.io/api/v1" ) -// DefaultConvergingStatusHandler reports whether the GameServer has reached its desired state. +// DefaultConvergingStatusHandler reports whether the MessageQueue has reached its desired state. func DefaultConvergingStatusHandler( - op concepts.ConvergingOperation, gs *examplev1.GameServer, + op concepts.ConvergingOperation, mq *examplev1.MessageQueue, ) (concepts.AliveStatusWithReason, error) { desired := int32(1) - if gs.Spec.Replicas != nil { - desired = *gs.Spec.Replicas + 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 gs.Status.ReadyReplicas == desired { + if mq.Status.ReadyReplicas == desired { return concepts.AliveStatusWithReason{ Status: concepts.AliveConvergingStatusHealthy, Reason: "All replicas are ready", @@ -238,32 +267,32 @@ func DefaultConvergingStatusHandler( return concepts.AliveStatusWithReason{ Status: status, - Reason: fmt.Sprintf("Waiting for replicas: %d/%d ready", gs.Status.ReadyReplicas, desired), + Reason: fmt.Sprintf("Waiting for replicas: %d/%d ready", mq.Status.ReadyReplicas, desired), }, nil } -// DefaultGraceStatusHandler reports health during convergence. -func DefaultGraceStatusHandler(gs *examplev1.GameServer) (concepts.GraceStatusWithReason, error) { +// DefaultGraceStatusHandler reports health once the grace period has expired. +func DefaultGraceStatusHandler(mq *examplev1.MessageQueue) (concepts.GraceStatusWithReason, error) { desired := int32(1) - if gs.Spec.Replicas != nil { - desired = *gs.Spec.Replicas + if mq.Spec.Replicas != nil { + desired = *mq.Spec.Replicas } - // Use == rather than >= so that grace and convergence agree on replica state. + // 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 that convergence considers non-healthy + // must not return Healthy for a state convergence considers non-healthy // (e.g. ReadyReplicas > desired during scale-down). - if gs.Status.ReadyReplicas == desired { + if mq.Status.ReadyReplicas == desired { return concepts.GraceStatusWithReason{ Status: concepts.GraceStatusHealthy, Reason: "All replicas are ready", }, nil } - if gs.Status.ReadyReplicas > 0 { + if mq.Status.ReadyReplicas > 0 { return concepts.GraceStatusWithReason{ Status: concepts.GraceStatusDegraded, - Reason: "GameServer partially available", + Reason: "MessageQueue partially available", }, nil } @@ -273,44 +302,41 @@ func DefaultGraceStatusHandler(gs *examplev1.GameServer) (concepts.GraceStatusWi }, nil } -// DefaultSuspensionStatusHandler reports whether the GameServer has been suspended. +// DefaultSuspensionStatusHandler reports progress towards a suspended state. func DefaultSuspensionStatusHandler( - gs *examplev1.GameServer, + mq *examplev1.MessageQueue, ) (concepts.SuspensionStatusWithReason, error) { - if gs.Status.Replicas == 0 { + if mq.Status.Replicas == 0 { return concepts.SuspensionStatusWithReason{ Status: concepts.SuspensionStatusSuspended, - Reason: "GameServer scaled to zero", + Reason: "MessageQueue scaled to zero", }, nil } return concepts.SuspensionStatusWithReason{ Status: concepts.SuspensionStatusSuspending, - Reason: fmt.Sprintf("%d replicas still running", gs.Status.Replicas), + Reason: fmt.Sprintf("%d replicas still running", mq.Status.Replicas), }, nil } -// DefaultSuspendMutationHandler scales the GameServer to zero replicas. +// 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.GameServer) bool { +func DefaultDeleteOnSuspendHandler(_ *examplev1.MessageQueue) bool { return false } ``` -#### Convergence and Grace Status Consistency - -The convergence handler and the grace handler evaluate the same object in the same reconcile loop with no refetch -between them. The grace handler must not return Healthy for any object state where the convergence handler returns -non-healthy. If this happens, one of the two handlers is misconfigured, and the component will log a warning. +### Keeping convergence and grace consistent -When convergence returns Healthy, the component is satisfied and grace is never called. For all other states, grace must -not contradict convergence by returning Healthy. The following table shows a consistent pair of handlers for a -Deployment with 3 desired replicas: +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 | | ------- | ----- | ----------- | ------------ | @@ -319,25 +345,18 @@ Deployment with 3 desired replicas: | 3 | 3 | Healthy | (not called) | | 3 | 5 | Scaling | Degraded | -A misconfigured grace handler that reports Healthy when the resource has not converged breaks this invariant: - -| Desired | Ready | Convergence | Grace | -| ------- | ----- | ----------- | ------------ | -| 3 | 0 | Creating | Down | -| 3 | 1 | Scaling | Degraded | -| 3 | 3 | Healthy | (not called) | -| 3 | 5 | Scaling | **Healthy** | - -In the last row, convergence considers the resource non-healthy (still scaling down), but grace tells the component -everything is fine. +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. -If this inconsistency is intentional (e.g., a custom grace handler that deliberately reports Healthy for a resource that -has not fully converged), pass `component.SuppressGraceInconsistencyWarning()` to `WithResource` to suppress the warning -log. +### Status constants reference -#### 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 Name | String Value | +| Category | Status type | Constant | String value | | --------------------- | -------------------------------- | ------------------------------- | ------------------- | | Workload | `concepts.AliveConvergingStatus` | `AliveConvergingStatusHealthy` | `Healthy` | | | | `AliveConvergingStatusCreating` | `Creating` | @@ -360,12 +379,25 @@ log. | All | `concepts.GuardStatus` | `GuardStatusBlocked` | `Blocked` | | | | `GuardStatusUnblocked` | `Unblocked` | -### 4. Implement the Builder +!!! 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 builder wraps the generic builder, registers default handlers, and exposes a fluent configuration API. +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 gameserver +package messagequeue import ( "fmt" @@ -376,26 +408,26 @@ import ( examplev1 "example.io/api/v1" ) -// Builder configures and validates a GameServer resource. +// Builder configures and validates a MessageQueue resource. type Builder struct { - base *generic.WorkloadBuilder[*examplev1.GameServer, *Mutator] + base *generic.WorkloadBuilder[*examplev1.MessageQueue, *Mutator] } -// NewBuilder creates a Builder with the provided GameServer as the desired base state. +// NewBuilder creates a Builder with the provided MessageQueue as the desired base state. // // The object must have Name and Namespace set. -func NewBuilder(gs *examplev1.GameServer) *Builder { - identityFunc := func(gs *examplev1.GameServer) string { - return fmt.Sprintf("gameservers.example.io/v1/GameServer/%s/%s", gs.Namespace, gs.Name) +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.GameServer, *Mutator]( - gs, + base := generic.NewWorkloadBuilder[*examplev1.MessageQueue, *Mutator]( + mq, identityFunc, NewMutator, ) - // Register default handlers. + // Register domain-specific defaults. base. WithCustomConvergeStatus(DefaultConvergingStatusHandler). WithCustomGraceStatus(DefaultGraceStatusHandler). @@ -415,15 +447,25 @@ func (b *Builder) WithMutation(ms ...Mutation) *Builder { return b } -// WithDataExtractor registers a data extractor to run after reconciliation. -func (b *Builder) WithDataExtractor(extractor func(examplev1.GameServer) error) *Builder { +// 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.GameServer) (concepts.AliveStatusWithReason, error), + handler func(concepts.ConvergingOperation, *examplev1.MessageQueue) (concepts.AliveStatusWithReason, error), ) *Builder { b.base.WithCustomConvergeStatus(handler) return b @@ -431,42 +473,12 @@ func (b *Builder) WithCustomConvergeStatus( // WithCustomGraceStatus overrides the default grace status handler. func (b *Builder) WithCustomGraceStatus( - handler func(*examplev1.GameServer) (concepts.GraceStatusWithReason, error), + handler func(*examplev1.MessageQueue) (concepts.GraceStatusWithReason, error), ) *Builder { b.base.WithCustomGraceStatus(handler) return b } -// WithCustomSuspendStatus overrides the default suspension status handler. -func (b *Builder) WithCustomSuspendStatus( - handler func(*examplev1.GameServer) (concepts.SuspensionStatusWithReason, error), -) *Builder { - b.base.WithCustomSuspendStatus(handler) - return b -} - -// WithCustomSuspendMutation overrides the default suspension mutation handler. -func (b *Builder) WithCustomSuspendMutation(handler func(*Mutator) error) *Builder { - b.base.WithCustomSuspendMutation(handler) - return b -} - -// WithCustomSuspendDeletionDecision overrides the default delete-on-suspend decision. -func (b *Builder) WithCustomSuspendDeletionDecision(handler func(*examplev1.GameServer) bool) *Builder { - b.base.WithCustomSuspendDeletionDecision(handler) - return b -} - -// WithGuard registers a guard precondition that is evaluated before the object -// is applied. If the guard returns Blocked, the 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.GameServer) (concepts.GuardStatusWithReason, error), -) *Builder { - b.base.WithGuard(generic.WrapGuard(guard)) - return b -} - // Build validates the configuration and returns the initialized Resource. func (b *Builder) Build() (*Resource, error) { genericRes, err := b.base.Build() @@ -477,26 +489,31 @@ func (b *Builder) Build() (*Resource, error) { } ``` -#### Builder Pattern Guidelines +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. -- **Only the convergence handler is required.** The generic builder's `Build()` returns an error if the convergence - status handler is not set (`ConvergingStatus` for workload/task, `OperationalStatus` for integration). Grace and - suspension handlers default to safe no-ops at the generic layer, so you only need to override them if your CRD has - domain-specific behavior for those lifecycle phases. -- **Register domain-specific defaults in the constructor.** Override the generic defaults where your CRD has meaningful - semantics (e.g., a grace handler that inspects replica counts, a suspension handler that scales to zero). -- **Return `*Builder` from every method.** This enables the fluent chaining pattern used throughout the framework. -- **Validate in `Build()`.** The generic builder's `Build()` validates that the object has a name, namespace (for - namespaced resources), identity function, mutator factory, and convergence handler. Add any custom validation after - calling the generic build. +!!! note "Builder conventions" -### 5. Implement the Resource + - **`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. -The resource is a thin wrapper that delegates every interface method to the generic resource. This layer exists so that -your package exports concrete types rather than generic ones. +--- + +## 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 gameserver +package messagequeue import ( "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" @@ -505,7 +522,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// Resource manages a GameServer within a component's reconciliation loop. +// Resource manages a MessageQueue within a component's reconciliation loop. // // It implements: // - component.Resource (Identity, Object, Mutate) @@ -515,8 +532,10 @@ import ( // - concepts.Guardable (GuardStatus) // - concepts.DataExtractable (ExtractData) // - concepts.ObservationRecorder (RecordObservation) +// - concepts.Previewable (Preview) +// - concepts.MutationInspector (RegisteredMutations, FiringSet) type Resource struct { - base *generic.WorkloadResource[*examplev1.GameServer, *Mutator] + base *generic.WorkloadResource[*examplev1.MessageQueue, *Mutator] } func (r *Resource) Identity() string { @@ -562,75 +581,109 @@ func (r *Resource) ExtractData() error { 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) ``` -Forward `RecordObservation` whenever the resource may be registered as read-only with a data extractor. The framework -uses it to feed 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. +!!! 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. -Which methods to include depends on your resource category: +`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. -| Category | Typical Methods | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workload | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation` | -| Static | `Identity`, `Object`, `Mutate`, `GuardStatus`, `ExtractData`, `RecordObservation` | -| Task | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation` | -| Integration | `Identity`, `Object`, `Mutate`, `ConvergingStatus`, `GraceStatus`, `DeleteOnSuspend`, `Suspend`, `SuspensionStatus`, `GuardStatus`, `ExtractData`, `RecordObservation` | +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. -### 6. Define Feature Mutations +Which methods to include depends on the category: -Feature mutations use the `Mutation` type alias you defined earlier. Each mutation declares a name, an optional feature -gate, and a function that calls mutator methods to record intent. +| 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/gameserver" + "example.io/messagequeue" ) -// HighCapacityMode increases the max player count for versions >= 2.0.0. -func HighCapacityMode(version string) gameserver.Mutation { - return gameserver.Mutation{ - Name: "high-capacity-mode", - Feature: feature.NewVersionGate(version, myVersionConstraints), - Mutate: func(m *gameserver.Mutator) error { - m.SetMaxPlayers(200) +// 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 }, } } -// CompetitiveMode enables competitive settings when the flag is set. -func CompetitiveMode(version string, enabled bool) gameserver.Mutation { - return gameserver.Mutation{ - Name: "competitive-mode", +// 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 *gameserver.Mutator) error { - m.SetMaxPlayers(10) + Mutate: func(m *messagequeue.Mutator) error { + m.SetMaxConnections(100) return nil }, } } -// DefaultSettings returns baseline mutations applied to every GameServer regardless of feature flags. +// 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) []gameserver.Mutation { - return []gameserver.Mutation{ +func DefaultSettings(version string) []messagequeue.Mutation { + return []messagequeue.Mutation{ { Name: "default-replicas", Feature: nil, // always applied - Mutate: func(m *gameserver.Mutator) error { + Mutate: func(m *messagequeue.Mutator) error { m.SetReplicas(1) return nil }, }, { - Name: "default-max-players", + Name: "default-max-connections", Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *gameserver.Mutator) error { - m.SetMaxPlayers(100) + Mutate: func(m *messagequeue.Mutator) error { + m.SetMaxConnections(500) return nil }, }, @@ -638,29 +691,33 @@ func DefaultSettings(version string) []gameserver.Mutation { } ``` -Mutations are applied in registration order. When a mutation's `Feature` is nil or reports `Enabled() == true`, its -`Mutate` function is called with the mutator. When `Enabled()` returns false, the mutation is skipped. +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). + +--- -### 7. Register with a Component +## 8. Register with a component -Use your custom resource with the component builder exactly like a built-in primitive: +Use your custom resource with the component builder exactly like a built-in primitive. ```go -func buildGameComponent(owner *MyOperatorCR) (*component.Component, error) { - gs := &examplev1.GameServer{ +func buildQueueComponent(owner *MyOperatorCR) (*component.Component, error) { + mq := &examplev1.MessageQueue{ ObjectMeta: metav1.ObjectMeta{ - Name: "main-server", + Name: "main-queue", Namespace: owner.Namespace, }, - Spec: examplev1.GameServerSpec{ - Replicas: ptr.To(int32(3)), - MaxPlayers: 100, + Spec: examplev1.MessageQueueSpec{ + Replicas: ptr.To(int32(3)), + MaxConnections: 500, }, } - res, err := gameserver.NewBuilder(gs). - WithMutation(features.HighCapacityMode(owner.Spec.Version)). - WithMutation(features.CompetitiveMode(owner.Spec.Version, owner.Spec.Competitive)). + 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 { @@ -668,8 +725,8 @@ func buildGameComponent(owner *MyOperatorCR) (*component.Component, error) { } return component.NewComponentBuilder(). - WithName("game-server"). - WithConditionType("GameServerReady"). + WithName("message-queue"). + WithConditionType("MessageQueueReady"). WithResource(res). WithGracePeriod(5 * time.Minute). Suspend(owner.Spec.Suspended). @@ -677,54 +734,130 @@ func buildGameComponent(owner *MyOperatorCR) (*component.Component, error) { } ``` +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. This changes validation to -reject a non-empty namespace instead of requiring one. - -The generic builder exposes this through the embedded `BaseBuilder`: +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(gs *examplev1.GameServer) *Builder { - base := generic.NewWorkloadBuilder[*examplev1.GameServer, *Mutator](gs, identityFunc, NewMutator) +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 + +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 +} -Static resources have the simplest implementation. They do not participate in convergence or suspension reporting. The -builder uses `generic.NewStaticBuilder` and only supports `WithMutation` and `WithDataExtractor`. The resource wrapper -only needs `Identity`, `Object`, `Mutate`, and `ExtractData`. +// 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, + ) -### Task Resources + base.WithCustomOperationalStatus(DefaultOperationalStatusHandler) -Task resources use `concepts.CompletionStatusWithReason` instead of `AliveStatusWithReason` for convergence. The -converging status handler reports `Completed`, `Running`, `Pending`, or `Failing`. + return &Builder{base: base} +} -### Integration Resources +// 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 +} +``` -Integration resources use `concepts.OperationalStatusWithReason` for convergence. The status handler reports -`Operational`, `Pending`, or `Failing`. They also implement `Graceful` for health assessment after grace period expiry, -with a default handler that reports Healthy. The resource wrapper should include `GraceStatus` alongside the other -methods. +`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, `ApplyMutations` helper | -| `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 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/docs/primitives.md b/docs/primitives.md index 75b1e7b7..0a67a071 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -1,208 +1,334 @@ -# Resource Primitives +# Primitives Overview -The `primitives` package provides reusable, type-safe wrappers for individual Kubernetes objects. Primitives sit between -the [Component layer](component.md) and raw Kubernetes resources. They handle the complexities of state synchronization, -mutation, and lifecycle management so operator authors don't have to. +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 (e.g., `Deployment`, `ConfigMap`) and encapsulates: +A primitive wraps a specific Kubernetes kind (for example `Deployment` or `ConfigMap`) and encapsulates: -- **Desired state baseline**: the ideal configuration of the resource. -- **Lifecycle integration**: built-in readiness detection, grace handling, and suspension. -- **Mutation surfaces**: typed APIs for modifying the resource based on active features or version constraints. -- **Server-Side Apply**: desired state is applied via SSA, preserving server defaults and fields managed by external +- **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. -Each primitive implements the `component.Resource` interface, and may additionally implement one or more +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 categorizes primitives based on their runtime behavior. +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` +Examples: `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`. -These resources have a mostly static desired state. They are created or updated based on configuration but have no -complex runtime convergence. They are considered `Ready` as long as they exist. They may optionally implement `Alive` or -`Operational` for more granular tracking. +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` +Examples: `Deployment`, `StatefulSet`, `DaemonSet`. -These resources represent 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. +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` +Examples: `Job`. -These resources represent short-lived operations that run to completion (database migrations, backups, initialization -steps). They implement `Completable` and `Suspendable`. When suspended, tasks can be paused (if the underlying resource -supports it) or deleted and recreated when resumed. +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`, `Gateway`, `CronJob` +Examples: `Service`, `Ingress`, `CronJob`, `HPA`. -These resources define integration points with external or cluster-level systems (networking, load balancers, DNS, -schedules). Their readiness depends on external controllers and may be delayed or partial. They implement `Operational`, -`Graceful`, and/or `Suspendable`. - -## Cluster-Scoped Primitives +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`. -Some Kubernetes resources are cluster-scoped: they have no namespace. Examples include `ClusterRole`, -`ClusterRoleBinding`, and `PersistentVolume`. - -When implementing a primitive for a cluster-scoped kind, the primitive's builder must explicitly call -`MarkClusterScoped()` on its internal `BaseBuilder` during construction. This changes `ValidateBase()` behavior: instead -of requiring a non-empty namespace, it rejects a non-empty namespace. The primitive's builder is also responsible for -providing an identity function that formats the identity string appropriately, typically omitting the namespace segment -(e.g., `rbac.authorization.k8s.io/v1/ClusterRole/my-role` rather than including a namespace). +## Lifecycle Interfaces -At reconcile time, the component framework automatically detects scope incompatibilities between the owner CRD and -managed resources using the cluster's REST mapper. See [Cluster-Scoped Resources](component.md#cluster-scoped-resources) -in the component documentation for details on owner reference behavior and garbage collection. +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. -## Lifecycle Interfaces +!!! note "This table is the single source of truth" -Primitives implement behavioral interfaces that the component layer uses for status aggregation: + 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 | Status values reported | Typical use | +| 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 | -| `Guardable` | `Blocked`, `Unblocked` | Resources with runtime preconditions | + +!!! 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. -## Server-Side Apply +## Cluster-Scoped Primitives -The framework reconciles resources using **Server-Side Apply** (SSA). Each primitive builds the desired state (the -baseline object with all registered mutations applied) and patches it to the cluster using `client.Apply`. Only 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. +Some Kubernetes kinds are cluster-scoped and have no namespace, for example `ClusterRole`, `ClusterRoleBinding`, and +`PersistentVolume`. -Field ownership is tracked automatically by the Kubernetes API server. The field manager name is derived from the owner -and component: `"{Owner.GetKind()}/{componentName}"`. The framework applies with forced ownership, meaning it will take -control of any conflicting fields from other managers. Fields that the operator does not include in its desired state -are left to their current owners. +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. -This approach removes the perpetual-update problem that arises when an operator strips server defaults every reconcile -cycle, and it allows primitives to coexist naturally in clusters where multiple controllers touch the same resources. +```text +object namespace cannot be empty +``` -## Mutation System +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: -Primitives use a **plan-and-apply pattern**: instead of mutating the Kubernetes object directly, mutations record their -intent through typed editors, which are applied in a single controlled pass. +```text +cluster-scoped object must not have a namespace +``` -This design: +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. -- **Prevents uncontrolled mutation**: changes are staged before any object is touched -- **Enables composability**: independent features contribute edits without knowing about each other -- **Guarantees ordering**: features apply in registration order; within a feature, categories apply in a fixed sequence -- **Avoids error-prone slice manipulation**: editors handle presence operations and stable selection internally +## 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. -### Registering multiple mutations +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. -`WithMutation` is variadic, so a single call can register several mutations, applied in the order given: +## 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 -b.WithMutation(first, second, third) +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 +} ``` -This composes cleanly with factories that return `[]Mutation`, without breaking the fluent chain: +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 -return statefulset.NewBuilder(base). - WithMutation(defaults.ContainerImage(version, registry)). - WithMutation(defaults.ClusterEnv(cc)...). - WithMutation(defaults.ExporterEnv(version)...). - Build() +b.WithMutation(first, second, third) ``` -Calling `WithMutation()` with no arguments is a no-op. +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 is 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 +``` -Mutation names must be unique within a resource. `Build` returns an error if two registered mutations share a `Name`, -because the name is the identifier that gating and error reporting refer to, and a collision would silently mask a -mis-targeted or dead mutation behind its namesake. The check compares names only and evaluates no feature gates. +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. -### Workload-kind-agnostic mutations +### Ordering within a feature -`*statefulset.Mutator`, `*deployment.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 framework interface covering exactly that shared surface, so a single mutation can -target any pod-workload kind. +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 -Write the emitter once against the interface, then lift it into each kind's `Mutation` with that package's -`LiftMutation` adapter before registering it: +A mutation can be enabled by a runtime condition rather than a version. Build a gate with no version constraints and add +boolean conditions through `When`: ```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" -) +import "github.com/sourcehawk/operator-component-framework/pkg/feature" -func emitAuthEnv() 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 - }, - } -} +gate := feature.NewVersionGate("", nil).When(len(spec.ExtraEnv) > 0) +``` + +`When` is additive: every value passed must be true for the gate to enable. An empty current version and `nil` +constraints mean only the boolean conditions decide. 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`: -zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) -gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) -nodeAgentDs.WithMutation(daemonset.LiftMutation(emitAuthEnv())) +```go +gate := feature.NewVersionGate(currentVersion, []feature.VersionConstraint{ + semver.MustConstraint(">= 2.0.0"), +}) ``` -Each package's `LiftMutation` returns that package's own `Mutation` type (`statefulset.LiftMutation` returns a -`statefulset.Mutation`, and so on), which is the concrete type that builder's `WithMutation` accepts. The lift is what -bridges an interface-typed emitter to the kind's concrete mutation type. The mutation's `Name` and `Feature` gate carry -through unchanged, so a lifted mutation gates and composes alongside natively-typed mutations on the same builder. +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: -The interface deliberately omits operations that are not common to all three kinds: the per-kind spec editors -(`EditStatefulSetSpec`, `EditDeploymentSpec`, `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. +```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 specific parts of a resource. Every editor exposes a `.Raw()` method -for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping -the mutation scoped to that editor's target. +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 documents its available editors in its own [Relevant Editors](#built-in-primitives) section. +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 -Selectors determine which containers an editor targets. This is important for multi-container pods: +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") // multiple containers 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) // container at a specific index +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())) ``` -Selectors are evaluated against the container list _after_ any presence operations (add/remove) within the same mutation -have been applied. This means a single mutation can safely add a container and then configure it. +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 @@ -232,88 +358,114 @@ have been applied. This means a single mutation can safely add a container and t ## Usage Examples -### Creating a 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 -} - -resource, err := deployment.NewBuilder(base). - Build() -``` - -### Adding a mutation - -```go -import ( - corev1 "k8s.io/api/core/v1" - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" -) - -resource, err := deployment.NewBuilder(base). - WithMutation(deployment.Mutation{ - Name: "add-proxy-sidecar", - Feature: feature.NewVersionGate(version, nil), - 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 +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, }, - }). - Build() -``` - -### Targeting multiple containers - -```go -m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error { - e.EnsureArg("--log-format=json") - return nil -}) -``` - -### Adding a guard - -Guards block a resource from being applied until a precondition is met. Combined with data extraction, they enable -runtime dependencies between resources: an earlier resource extracts data after it is applied, and a later resource's -guard checks that data before proceeding. - -```go -resource, err := deployment.NewBuilder(base). - WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) { - if roleARN == "" { - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusBlocked, - Reason: "waiting for IAM role ARN", - }, nil - } - return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil - }). - Build() -``` - -Guards handle dependencies between resources **within** a single component. For dependencies **between** components -(e.g., "the web interface cannot start until the database is ready"), use [prerequisites](component.md#prerequisites) on -the component builder instead. - -See [Guards](component.md#guards) in the component documentation for the full behavioral contract and a complete example -showing data extraction feeding into a guard. + 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 @@ -325,22 +477,20 @@ showing data extraction feeding into a guard. | `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, Crossplane resources, external CRDs, or any object known only at runtime. One variant exists per -[lifecycle category](#primitive-categories), each implementing the corresponding interfaces. - -Because the framework cannot know the semantics of an unstructured object, it does **not infer any semantic or -domain-specific defaults**. The builders instead configure generic safe defaults: if you omit a grace handler, the -primitive treats the resource as Healthy; if you omit suspension handlers, the primitive reports Suspended and the -suspend mutation is a no-op. Only the converge/operational status handler is required at build time. +example external CRDs or any object known only at runtime. One variant exists per [category](#primitive-categories), +each implementing the matching lifecycle interfaces. -The unstructured primitives share a single `Mutator` and use an `UnstructuredContentEditor` for manipulating nested -fields in the object's content map. See [unstructured.md](primitives/unstructured.md) for full details. +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 use case, you can implement custom resource wrappers for any Kubernetes -object, including custom CRDs. The framework provides generic building blocks in `pkg/generic` that handle -reconciliation mechanics, mutation sequencing, and suspension, so you only need to provide type-specific logic. +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/mkdocs.yml b/mkdocs.yml index 4ff0a180..e8fee6e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -113,3 +113,4 @@ nav: - Testing: testing.md - Reference: - Compatibility: compatibility.md + - Go API ↗: https://pkg.go.dev/github.com/sourcehawk/operator-component-framework From f4945d8172ffe1cba98329faf02ac291e649cf38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:11:29 +0200 Subject: [PATCH 05/37] docs: add getting-started tutorial and rework landing page Landing page: add a value proposition, key-features summary, and a minimal code taste, with the card grid as secondary navigation and a suggested reading path. Getting Started: a hands-on first-component tutorial grounded in a real example, covering the owner CRD contract, building ConfigMap and Deployment primitives, a boolean-gated mutation, composing the component, wiring a thin reconciler (with the Kubebuilder fetch-and-delegate entry point and FlushStatus status-write semantics), and a golden test. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/getting-started.md | 416 +++++++++++++++++++++++++++++++++++++++- docs/index.md | 65 ++++++- 2 files changed, 468 insertions(+), 13 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 0880674e..cef81902 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,415 @@ # Getting Started -A step-by-step walkthrough that builds your first component is in progress and will land in this section. +This tutorial builds your first component from scratch: one component that manages a ConfigMap and a Deployment, reports +a single condition on the owner object, and gates one piece of behavior behind a boolean flag. By the end you have a +working reconcile loop and a golden test pinning the rendered output. -In the meantime, the [Component](component.md) and [Primitives](primitives.md) pages cover the core concepts, and the -[README quick start](https://github.com/sourcehawk/operator-component-framework#quick-start) shows a minimal end-to-end -example. +Every snippet here is taken from the `mutations-and-gating` example in the repository. If you want the finished code +side by side, open `examples/mutations-and-gating`. + +## Requirements + +- A controller-runtime project, such as one scaffolded with [Kubebuilder](https://book.kubebuilder.io/). This framework + is not a replacement for [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime); it is a library + you use inside a reconciler to manage the layers between the reconciler and the Kubernetes objects it manages. +- An owner CRD type whose status implements `GetStatusConditions() *[]metav1.Condition`. The framework stages and + flushes conditions through this accessor. +- The module installed: + +```bash +go get github.com/sourcehawk/operator-component-framework +``` + +See [Compatibility](compatibility.md) for the supported Go and controller-runtime versions. + +## What you will build + +A single component named `example-app` with the condition type `AppReady`. It manages: + +- A **ConfigMap** holding the application configuration. +- A **Deployment** running the application container, with one boolean-gated mutation that sets `LOG_LEVEL=debug` when a + spec flag is enabled. + +The reconciler builds the component and hands it to the framework, which applies the resources, aggregates their health, +and writes one condition back to the owner. + +## Step 1: Define the owner CRD + +The framework only requires one thing of your CRD: the status type exposes its conditions through `GetStatusConditions`. +Here is a minimal owner with a version, one feature flag, and a conditions slice. + +```go +package app + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ExampleAppSpec defines the desired state of ExampleApp. +type ExampleAppSpec struct { + // Version of the application to deploy. + Version string `json:"version"` + + // EnableDebugLogging sets LOG_LEVEL=debug on the application container. + EnableDebugLogging bool `json:"enableDebugLogging"` + + // Suspended determines whether the application is active. + Suspended bool `json:"suspended"` +} + +// ExampleAppStatus defines the observed state of ExampleApp. +type ExampleAppStatus struct { + // Conditions store the status of the application's components. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// ExampleApp is the owner CRD managed by the operator. +type ExampleApp struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ExampleAppSpec `json:"spec,omitempty"` + Status ExampleAppStatus `json:"status,omitempty"` +} + +// GetStatusConditions returns the status conditions for the ExampleApp. +// The framework reads and writes the owner's conditions through this accessor. +func (in *ExampleApp) GetStatusConditions() *[]metav1.Condition { + return &in.Status.Conditions +} +``` + +A real CRD also implements `runtime.Object` (the `DeepCopyObject` method and a list type) and registers with a scheme. +Kubebuilder generates that boilerplate for you. The full shared type lives in `examples/shared/app/owner.go`. + +## Step 2: Build the ConfigMap primitive + +A primitive wraps one Kubernetes object. Start by writing a function that returns the desired-state object, then build a +`component.Resource` from it with the primitive builder. + +```go +package resources + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "your.module/app" +) + +// BaseConfigMap returns the desired-state ConfigMap for the given owner. +func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-config", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Data: map[string]string{ + "app.yaml": "server:\n port: 8080\n timeout: 30s\n", + }, + } +} + +// NewConfigMapResource builds the ConfigMap resource. +func NewConfigMapResource(owner *app.ExampleApp) (component.Resource, error) { + return configmap.NewBuilder(BaseConfigMap(owner)).Build() +} +``` + +`NewBuilder` takes the object, `Build` returns a `component.Resource` ready to register on a component. + +## Step 3: Build the Deployment primitive + +The Deployment is built the same way, with one addition: a boolean-gated mutation. A mutation is a named edit that the +framework applies only when its feature gate is active. Defining the edit separately from the baseline keeps the +baseline readable and the conditional behavior testable on its own. + +First, the baseline. Define it as the latest version's desired shape and leave version-dependent fields out of it. This +is the baseline-as-latest convention; the [Guidelines](guidelines.md) explain why it pays off. + +```go +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "your.module/app" +) + +// BaseDeployment returns the desired-state Deployment for the given owner. +func BaseDeployment(owner *app.ExampleApp) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-app", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": owner.Name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": owner.Name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: fmt.Sprintf("my-app:%s", owner.Spec.Version), + }, + }, + }, + }, + }, + } +} +``` + +Now the mutation. It targets the container named `app` and sets an environment variable. The gate is built with +`feature.NewVersionGate("", nil).When(enabled)`: passing an empty version and no constraints, then `.When(enabled)`, +yields a gate that fires purely on the boolean. When `enabled` is `false`, the framework skips the edit. + +```go +package features + +import ( + "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" + corev1 "k8s.io/api/core/v1" +) + +// DebugLoggingMutation sets LOG_LEVEL=debug on the application container when enabled. +func DebugLoggingMutation(enabled bool) deployment.Mutation { + return deployment.Mutation{ + Name: "DebugLogging", + Feature: feature.NewVersionGate("", nil).When(enabled), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil + }) + return nil + }, + } +} +``` + +Register the mutation when building the resource. Mutations apply in registration order. + +```go +// NewDeploymentResource builds the Deployment resource with its mutations. +func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) { + return deployment.NewBuilder(BaseDeployment(owner)). + WithMutation(features.DebugLoggingMutation(owner.Spec.EnableDebugLogging)). + Build() +} +``` + +!!! note + + A non-empty version and a constraint slice turn the same gate into a version-gated mutation, which fires only when + the version satisfies the constraint. That is how backward compatibility patches are expressed without touching the + baseline. See [Primitives](primitives.md) for the mutation system and [Guidelines](guidelines.md) for the + baseline-as-latest pattern. + +## Step 4: Compose the component + +A component groups resources under one name and one condition type. Register resources in dependency order; they +reconcile in the order you add them. + +```go +comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(deployResource). + WithResource(cmResource). + Suspend(owner.Spec.Suspended). + Build() +if err != nil { + return err +} +``` + +`Suspend(true)` deactivates the component's suspendable resources (the Deployment scales to zero) without deleting the +component's record of them. + +## Step 5: Wire the reconciler + +The controller stays thin. It builds the resources, builds the component, and calls `Reconcile`. A `ReconcileContext` +carries the client, scheme, recorders, and owner. A single deferred `component.FlushStatus` persists every staged +condition with one status update at the end of the loop, which keeps controllers with multiple components free of +self-induced update conflicts. + +`ReconcileContext` has five fields: + +| Field | Type | Notes | +| ---------- | ----------------------- | -------------------------------------------------------------------------------- | +| `Client` | `client.Client` | The controller-runtime client. | +| `Scheme` | `*runtime.Scheme` | The operator scheme. | +| `Recorder` | `record.EventRecorder` | For Kubernetes events. Your Kubebuilder manager provides one. | +| `Metrics` | `component.Recorder` | Optional. Pass `nil` to skip status-condition metrics. | +| `Owner` | `component.OperatorCRD` | The owner object you fetched. Your CRD satisfies this via `GetStatusConditions`. | + +!!! note + + `FlushStatus` performs the status write itself: it calls `Client.Status().Update` on the owner (retrying on + conflict). Do not also call `Status().Update` for the conditions the framework manages, or you will double-write. + Because the call is deferred, the conditions are flushed even if `Reconcile` returns an error. + +```go +package app + +import ( + "context" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Controller struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + NewDeploymentResource func(*ExampleApp) (component.Resource, error) + NewConfigMapResource func(*ExampleApp) (component.Resource, error) +} + +func (r *Controller) reconcile(ctx context.Context, owner *ExampleApp) (err error) { + 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 + } + }() + + deployResource, err := r.NewDeploymentResource(owner) + if err != nil { + return err + } + + cmResource, err := r.NewConfigMapResource(owner) + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(deployResource). + WithResource(cmResource). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + return comp.Reconcile(ctx, recCtx) +} +``` + +The `reconcile` method above takes the owner directly so the framework logic is easy to read and test. In a Kubebuilder +project the generated entry point has the signature `Reconcile(ctx, req)`. Fetch the owner there, handle the not-found +case, and delegate to it: + +```go +func (r *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + owner := &ExampleApp{} + if err := r.Get(ctx, req.NamespacedName, owner); err != nil { + // Ignore not-found: the owner was deleted and its resources are + // garbage-collected through their owner references. + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + return ctrl.Result{}, r.reconcile(ctx, owner) +} +``` + +This entry point uses `ctrl "sigs.k8s.io/controller-runtime"` for `ctrl.Request` and `ctrl.Result`. After a reconcile, +the owner's `Status.Conditions` carries one `AppReady` condition reflecting the aggregated health of the Deployment and +ConfigMap. + +## Step 6: Add a golden test + +A golden test renders a resource to YAML and compares it against a checked-in snapshot. It catches unintended changes to +the rendered output, including changes a mutation makes for a given flag value. Use `golden.AssertYAML` for a single +resource, with a `-update` flag to regenerate the snapshot. + +For typed Kubernetes objects, pass a scheme through `golden.WithScheme` so the serialized output carries `apiVersion` +and `kind`. + +```go +package resources_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + + "your.module/app" + "your.module/features" + "your.module/resources" +) + +var update = flag.Bool("update", false, "update golden files") + +func TestDeploymentShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + + owner := &app.ExampleApp{Spec: app.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}} + owner.Name = "my-app" + owner.Namespace = "default" + + res, err := deployment.NewBuilder(resources.BaseDeployment(owner)). + WithMutation(features.DebugLoggingMutation(owner.Spec.EnableDebugLogging)). + Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/deployment.yaml", res, golden.WithScheme(scheme), golden.Update(*update)) +} +``` + +Generate the snapshot, then run the test normally to verify it stays stable: + +```bash +go test ./resources -run TestDeploymentShape -update +go test ./resources -run TestDeploymentShape +``` + +Commit the generated `testdata/deployment.yaml` alongside the test. To assert the rendered output of an entire component +at once, use `golden.AssertComponentYAML`, which serializes every resource the component would apply into one +multi-document file. + +## Next steps + +- [Component](component.md): the full lifecycle, status model, grace periods, suspension, guards, and prerequisites. +- [Guidelines](guidelines.md): the baseline-as-latest convention, one component per condition, thin controllers, and + version-gated mutations in practice. +- [Testing](testing.md): golden snapshots in depth and version-matrix golden generation across supported versions. diff --git a/docs/index.md b/docs/index.md index 1b15b383..4a2467b3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,17 +1,64 @@ # Operator Component Framework -A Go framework for building Kubernetes operators that stay maintainable as they grow. It pulls reconciliation mechanics, -status reporting, and lifecycle behavior into reusable building blocks (**components** and **resource primitives**), so -your controllers stay thin and focused on construction and orchestration. +A Go framework for building Kubernetes operators that stay maintainable as they grow. -!!! note +## Why this exists - This framework is not a replacement for - [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime). It is a library you use inside - controller-runtime reconcilers (such as Kubebuilder-generated projects) to manage the layers between the reconciler - and the Kubernetes resources it manages. +Operators tend to accumulate the same problems. Status conditions are assembled by hand, and aggregating them into a +single owner condition without provoking update conflicts is fiddly to get right. Reconcilers grow into fat, +hard-to-test functions that mix construction, ordering, health checks, and status writes. Version-gating logic ends up +scattered through the reconcile path as conditionals that are easy to break and hard to review. -## Start here +This framework moves that work into two reusable layers, **components** and **resource primitives**, that sit between +your reconciler and the Kubernetes objects it manages. Reconciliation mechanics, health aggregation, and feature gating +live in the framework, so controllers stay thin and the version-specific behavior lives in named, testable mutations. + +## Key features + +**Reconciliation and status** + +- Resource primitives report health in a way that fits their category, and the component aggregates them into one owner + condition with a single status write. +- Grace periods give resources time to converge before a component reports degraded or down. + +**Feature and version management** + +- Mutations apply patches only when a flag is set or a version constraint matches, keeping the baseline object clean. +- Feature gates enable or disable an entire component, or an individual resource within one, based on flags or version + ranges. + +**Orchestration** + +- Guards block a resource and everything after it until a precondition is met. +- Data extraction harvests values from one resource for guards and mutations on later ones. +- Prerequisites express startup ordering between components. + +## A taste + +A component composes resource primitives into one reconcilable unit with a single owner condition. The reconciler builds +it and hands it to the framework. + +```go +comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(deployResource). + WithResource(cmResource, component.DeleteWhen(!owner.Spec.EnableMetrics)). + Suspend(owner.Spec.Suspended). + Build() +if err != nil { + return err +} + +return comp.Reconcile(ctx, recCtx) +``` + +[Getting Started](getting-started.md) walks through building `deployResource` and `cmResource` and wiring the reconcile +loop end to end. + +## Where to go next + +New to the framework? Start with **Getting Started**. Already building and looking for patterns? Read **Guidelines**.
From 9ff2388724f02c3c4f9f1ddc863937de1cccccf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:22:14 +0200 Subject: [PATCH 06/37] docs: rewrite primitive reference pages to canonical template Standardize all 22 primitive pages on one template (capabilities, building, mutations, internal ordering, editors, kind-specific sections, lifecycle sections, full example, guidance). Shared cross-cutting concepts now live once in the Primitives Overview and each page links to them rather than repeating. Notable fixes verified against source: - Use runtime string status values consistently (e.g. OperationPending), fixing service, pv, pvc, hpa, pdb which previously used wrong or Go-identifier forms. - Add workload-kind-agnostic mutation pointers only where LiftMutation exists (deployment, statefulset, daemonset). - Demonstrate WithDataExtractor on static primitives that claimed the capability but never showed it. - Add the garbage-collection ownership warning and a full example to clusterrolebinding; remove the duplicated server-side apply section from cronjob; expand the previously thin pod and replicaset pages. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/primitives/clusterrole.md | 192 ++++++++--------- docs/primitives/clusterrolebinding.md | 201 +++++++++++------- docs/primitives/configmap.md | 125 ++++------- docs/primitives/cronjob.md | 202 +++++++++++------- docs/primitives/daemonset.md | 186 ++++++++++------- docs/primitives/deployment.md | 197 +++++++---------- docs/primitives/hpa.md | 255 ++++++++++------------ docs/primitives/ingress.md | 185 +++++++++------- docs/primitives/job.md | 174 ++++++++-------- docs/primitives/networkpolicy.md | 119 ++++------- docs/primitives/pdb.md | 186 ++++++++--------- docs/primitives/pod.md | 185 ++++++++-------- docs/primitives/pv.md | 130 ++++-------- docs/primitives/pvc.md | 192 +++++++++-------- docs/primitives/replicaset.md | 160 +++++++++----- docs/primitives/role.md | 164 ++++++--------- docs/primitives/rolebinding.md | 203 ++++++++++-------- docs/primitives/secret.md | 167 ++++++++------- docs/primitives/service.md | 212 ++++++++----------- docs/primitives/serviceaccount.md | 155 +++++++------- docs/primitives/statefulset.md | 180 +++++++--------- docs/primitives/unstructured.md | 290 ++++++++++++++++++-------- 22 files changed, 2058 insertions(+), 2002 deletions(-) diff --git a/docs/primitives/clusterrole.md b/docs/primitives/clusterrole.md index d2de78e4..5b8f6a25 100644 --- a/docs/primitives/clusterrole.md +++ b/docs/primitives/clusterrole.md @@ -1,27 +1,28 @@ # ClusterRole Primitive -The `clusterrole` primitive is the framework's built-in static abstraction for managing Kubernetes `ClusterRole` -resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.rules`, -`.aggregationRule`, and object metadata. +The `clusterrole` primitive wraps a Kubernetes `ClusterRole` and manages RBAC policy rules, aggregation rules, and +object metadata within the component lifecycle. -ClusterRole is cluster-scoped: it has no namespace. The builder validates that the Name is set and that Namespace is -empty. Setting a namespace on a cluster-scoped resource is rejected. +!!! warning "Ownership limitation for namespaced owners" -> **Ownership limitation:** During reconciliation, the framework attempts to set a controller reference on managed -> objects, but only when the owner and dependent scopes are compatible. When a namespaced owner manages a cluster-scoped -> resource such as a `ClusterRole`, the owner reference is skipped (and this is logged) instead of causing the reconcile -> to fail. In this case, the `ClusterRole` is **not** owned by the custom resource for Kubernetes garbage-collection or -> ownership semantics, so it will not be automatically deleted when the owner is removed; you must handle its lifecycle -> explicitly or use a cluster-scoped owner if automatic cleanup is required. + 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 | Detail | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | -| **Mutation pipeline** | Typed editors (`PolicyRulesEditor`) for `.rules` and object metadata, with aggregation rule support and a raw escape hatch | -| **Cluster-scoped** | No namespace required. Identity format is `rbac.authorization.k8s.io/v1/ClusterRole/` | -| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRole after each sync cycle | +| 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 @@ -42,71 +43,30 @@ base := &rbacv1.ClusterRole{ } resource, err := clusterrole.NewBuilder(base). - WithMutation(MyFeatureMutation(owner.Spec.Version)). + WithMutation(CRDAccessMutation(owner.Spec.Version, owner.Spec.ManageCRDs)). Build() ``` -## Mutations - -Mutations are the primary mechanism for modifying a `ClusterRole` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally: - -```go -func PodReadMutation() clusterrole.Mutation { - return clusterrole.Mutation{ - Name: "pod-read", - Mutate: func(m *clusterrole.Mutator) error { - m.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"pods"}, - Verbs: []string{"get", "list", "watch"}, - }) - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. +`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. -### Boolean-gated mutations +Identity format: `rbac.authorization.k8s.io/v1/ClusterRole/`. -```go -func SecretAccessMutation(version string, needsSecrets bool) clusterrole.Mutation { - return clusterrole.Mutation{ - Name: "secret-access", - Feature: feature.NewVersionGate(version, nil).When(needsSecrets), - Mutate: func(m *clusterrole.Mutator) error { - m.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{"get", "list"}, - }) - return nil - }, - } -} -``` +## Mutations -### Version-gated 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 -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyRBACMutation(version string) clusterrole.Mutation { +func CRDAccessMutation(version string, manageCRDs bool) clusterrole.Mutation { return clusterrole.Mutation{ - Name: "legacy-rbac", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), + Name: "crd-access", + Feature: feature.NewVersionGate(version, nil).When(manageCRDs), Mutate: func(m *clusterrole.Mutator) error { m.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{"extensions"}, - Resources: []string{"deployments"}, - Verbs: []string{"get", "list"}, + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"get", "list", "watch"}, }) return nil }, @@ -114,50 +74,40 @@ func LegacyRBACMutation(version string) clusterrole.Mutation { } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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 -The Mutator maintains feature boundaries: each feature's mutations are planned together and applied in the order the -features were registered. Within each feature, edits are applied in a fixed category order: +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` entries: EditRules, AddRule | -| 3 | Aggregation rule | `.aggregationRule`: SetAggregationRule | +| 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 are applied in their registration order. For aggregation rules, the last -`SetAggregationRule` call wins within each feature. Later features observe the ClusterRole as modified by all previous -features. +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` entries. Use `m.EditRules` for full control: - -```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 -}) -``` +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: +`AddRule` appends a `PolicyRule` to the rules slice: ```go m.EditRules(func(e *editors.PolicyRulesEditor) error { e.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"configmaps"}, - Verbs: []string{"get", "list"}, + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, }) return nil }) @@ -165,7 +115,7 @@ m.EditRules(func(e *editors.PolicyRulesEditor) error { #### RemoveRuleByIndex -`RemoveRuleByIndex` removes the rule at the given index. It is a no-op if the index is out of bounds: +`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 { @@ -199,9 +149,8 @@ m.EditRules(func(e *editors.PolicyRulesEditor) error { ### ObjectMetaEditor -Modifies labels and annotations via `m.EditObjectMetadata`. - -Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. ```go m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { @@ -213,7 +162,7 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Convenience Methods -The `Mutator` exposes a convenience wrapper for the most common `.rules` operation: +The `*Mutator` exposes a direct convenience method for the most common `.rules` operation: | Method | Equivalent to | | --------------- | ------------------------------- | @@ -235,9 +184,26 @@ m.SetAggregationRule(&rbacv1.AggregationRule{ }) ``` -Setting the aggregation rule to nil clears it. Within a single feature, the last `SetAggregationRule` call wins. +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 -## Full Example: Feature-Composed RBAC +`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 { @@ -280,12 +246,18 @@ written. Neither mutation needs to know about the other. ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**`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 based on label selectors, use `SetAggregationRule` instead of managing `.rules` directly. The two -approaches are mutually exclusive in the Kubernetes API: the API server ignores `.rules` when `.aggregationRule` is set. +ClusterRoles via label selectors, call `SetAggregationRule` instead of managing `.rules` directly. Do not mix both +approaches on the same role. -**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. +**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/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index 99836639..94c3a6fa 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -1,17 +1,29 @@ # ClusterRoleBinding Primitive -The `clusterrolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes -`ClusterRoleBinding` resources. It integrates with the component lifecycle and provides a structured mutation API for -managing `.subjects` entries and object metadata. +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 | Detail | -| --------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | -| **Cluster-scoped** | Cluster-scoped resource. Build() validates Name and requires metadata.namespace to be empty (errors if set) | -| **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access | -| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle | +| 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 @@ -37,44 +49,28 @@ base := &rbacv1.ClusterRoleBinding{ } resource, err := clusterrolebinding.NewBuilder(base). - WithMutation(MySubjectMutation(owner.Spec.Version)). + WithMutation(ExtraSubjectMutation(owner.Spec.Version, owner.Spec.EnableExtra)). Build() ``` -## Mutations - -Mutations are the primary mechanism for modifying a `ClusterRoleBinding` beyond its baseline. Each mutation is a named -function that receives a `*Mutator` and records edit intent through typed editors. +`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. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +`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. -```go -func MySubjectMutation(version string) clusterrolebinding.Mutation { - return clusterrolebinding.Mutation{ - Name: "my-subjects", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *clusterrolebinding.Mutator) error { - m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureServiceAccount("my-sa", "default") - return nil - }) - return nil - }, - } -} -``` +Identity format: `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. +## Mutations -### Boolean-gated 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 ConditionalSubjectMutation(version string, addExtraSubject bool) clusterrolebinding.Mutation { +func ExtraSubjectMutation(version string, enabled bool) clusterrolebinding.Mutation { return clusterrolebinding.Mutation{ - Name: "conditional-subject", - Feature: feature.NewVersionGate(version, nil).When(addExtraSubject), + 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") @@ -86,26 +82,28 @@ func ConditionalSubjectMutation(version string, addExtraSubject bool) clusterrol } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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: Add, Remove, EnsureServiceAccount | +| 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 are applied in their registration order. Later features observe the ClusterRoleBinding as -modified by all previous features. +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` entries. Use `m.EditSubjects` for full control: +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 { @@ -117,42 +115,33 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { #### EnsureSubject -Upserts a subject in the subjects list. A subject is identified by the combination of Kind, Name, and Namespace. If a -matching subject already exists it is replaced; otherwise the new subject is appended: +`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 -m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureSubject(rbacv1.Subject{ - Kind: "Group", - Name: "developers", - APIGroup: "rbac.authorization.k8s.io", - }) - return nil +e.EnsureSubject(rbacv1.Subject{ + Kind: "Group", + Name: "developers", + APIGroup: "rbac.authorization.k8s.io", }) ``` #### EnsureServiceAccount -Convenience method that ensures a `ServiceAccount` subject with the given name and namespace exists: +Convenience wrapper that ensures a `ServiceAccount` subject with the given name and namespace exists: ```go -m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureServiceAccount("app-sa", "production") - return nil -}) +e.EnsureServiceAccount("app-sa", "production") ``` #### RemoveSubject and RemoveServiceAccount -`RemoveSubject` removes a subject matching the given kind, name, and namespace. `RemoveServiceAccount` is a convenience +`RemoveSubject` removes a subject identified by kind, name, and namespace. `RemoveServiceAccount` is a convenience wrapper for removing `ServiceAccount` subjects: ```go -m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.RemoveSubject("User", "old-user", "") - e.RemoveServiceAccount("deprecated-sa", "default") - return nil -}) +e.RemoveSubject("User", "old-user", "") +e.RemoveServiceAccount("deprecated-sa", "default") ``` #### Raw Escape Hatch @@ -173,25 +162,83 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { ### ObjectMetaEditor -Modifies labels and annotations via `m.EditObjectMetadata`. - -Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. +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 admin binding") + 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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**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 have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or -validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. +**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. -**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first. +**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/docs/primitives/configmap.md b/docs/primitives/configmap.md index 39d4e328..0b38fde1 100644 --- a/docs/primitives/configmap.md +++ b/docs/primitives/configmap.md @@ -1,17 +1,19 @@ # ConfigMap Primitive -The `configmap` primitive is the framework's built-in static abstraction for managing Kubernetes `ConfigMap` resources. -It integrates with the component lifecycle and provides a structured mutation API for managing `.data` entries and -object metadata. +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 for free-form access | -| **MergeYAML** | Deep-merges YAML patches into individual `.data` entries; composable across independent features | -| **Data extraction** | Reads generated or updated values back from the reconciled ConfigMap after each sync cycle | +| 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 @@ -35,17 +37,18 @@ resource, err := configmap.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `ConfigMap` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +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), // always enabled + Feature: feature.NewVersionGate(version, nil), Mutate: func(m *configmap.Mutator) error { m.SetEntry("feature-flag", "enabled") return nil @@ -54,61 +57,22 @@ func MyFeatureMutation(version string) configmap.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -```go -func TLSConfigMutation(version string, tlsEnabled bool) configmap.Mutation { - return configmap.Mutation{ - Name: "tls-config", - Feature: feature.NewVersionGate(version, nil).When(tlsEnabled), - Mutate: func(m *configmap.Mutator) error { - m.SetEntry("tls_mode", "strict") - return nil - }, - } -} -``` - -### Version-gated mutations - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyAuthMutation(version string) configmap.Mutation { - return configmap.Mutation{ - Name: "legacy-auth", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *configmap.Mutator) error { - m.SetEntry("auth_mode", "legacy-token") - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 are applied in their registration order. Later features observe the ConfigMap as modified by -all previous features. +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: @@ -136,7 +100,7 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error { #### 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. No helpers are provided beyond set and remove. Format and encode the value before passing it in. +is absent. Format and encode the value before passing it in. ```go m.EditData(func(e *editors.ConfigMapDataEditor) error { @@ -156,8 +120,8 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error { - 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 feature needing to know about -the others: +This makes it suitable for composing contributions from independent features without each needing to know about the +others: ```go // Feature A contributes logging config. @@ -175,7 +139,7 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error { #### 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 when none of the structured methods are sufficient: +for `.binaryData`. Both give direct access for free-form editing: ```go m.EditData(func(e *editors.ConfigMapDataEditor) error { @@ -218,9 +182,8 @@ single edit block. ## Data Hash -Two utilities are provided for computing 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. +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 @@ -231,7 +194,7 @@ 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 (labels, annotations, etc.) are excluded. +so it is deterministic regardless of insertion order. Metadata fields are excluded. ### Resource.DesiredHash @@ -247,12 +210,12 @@ cmResource, err := configmap.NewBuilder(base). hash, err := cmResource.DesiredHash() ``` -The hash covers only operator-controlled fields. Only changes to operator-owned content will change the hash. +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 +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 @@ -278,7 +241,7 @@ if err != nil { } comp, err := component.NewComponentBuilder(). - WithResource(cmResource). // reconciled first + WithResource(cmResource). // reconciled first WithResource(deployResource). Build() ``` @@ -300,10 +263,10 @@ func ChecksumAnnotationMutation(version, configHash string) deployment.Mutation } ``` -When the configmap mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the +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: Feature-Composed Configuration +## Full Example ```go func BaseConfigMutation(version string) configmap.Mutation { @@ -346,17 +309,19 @@ resource, err := configmap.NewBuilder(base). Build() ``` -When `MetricsEnabled` is true, the final `app.yaml` entry will contain the merged result of both patches. When false, -only the base config is written. Neither mutation needs to know about the other. +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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**`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 need to contribute to the same YAML entry, -`MergeYAML` lets each feature 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. +**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/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index 20eaa329..8dab0ea0 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -1,23 +1,18 @@ # CronJob Primitive -The `cronjob` primitive is the framework's built-in integration abstraction for managing Kubernetes `CronJob` resources. -It integrates with the component lifecycle through the Operational, Graceful, and Suspendable concepts, and provides a -rich mutation API for managing the CronJob schedule, job template, pod spec, and containers. +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 -| Capability | Detail | -| ------------------------ | ------------------------------------------------------------------------------------------- | -| **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) | -| **Grace status** | Always reports `Healthy`. A CronJob is a passive scheduler and is healthy once it exists | -| **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | - -## Server-Side Apply - -The CronJob primitive reconciles resources using **Server-Side Apply** (SSA). Only fields declared by the operator are -sent; server-managed defaults, fields set by other controllers, and values written by webhooks are left untouched. Field -ownership is tracked automatically by the Kubernetes API server. +| [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 @@ -35,10 +30,10 @@ base := &batchv1.CronJob{ Spec: batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, Containers: []corev1.Container{ - {Name: "cleanup", Image: "cleanup:latest"}, + {Name: "cleanup", Image: "cleanup-tool:latest"}, }, - RestartPolicy: corev1.RestartPolicyOnFailure, }, }, }, @@ -53,13 +48,12 @@ resource, err := cronjob.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `CronJob` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. +Each mutation is a named `cronjob.Mutation` that receives a `*cronjob.Mutator` and records edits through typed editors. ```go -func MyScheduleMutation(version string) cronjob.Mutation { +func ScheduleMutation(version string) cronjob.Mutation { return cronjob.Mutation{ - Name: "my-schedule", + Name: "schedule", Feature: feature.NewVersionGate(version, nil), Mutate: func(m *cronjob.Mutator) error { m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { @@ -73,34 +67,19 @@ func MyScheduleMutation(version string) cronjob.Mutation { } ``` -### Boolean-gated mutations +See [the mutation system](../primitives.md#the-mutation-system), +[boolean gating](../primitives.md#boolean-gated-mutations), and +[version gating](../primitives.md#version-gated-mutations). -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func TimeZoneMutation(version string, enabled bool) cronjob.Mutation { - return cronjob.Mutation{ - Name: "timezone", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *cronjob.Mutator) error { - m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { - e.SetTimeZone("America/New_York") - return nil - }) - return nil - }, - } -} -``` +For all primitives, desired state is reconciled via [Server-Side Apply](../primitives.md#server-side-apply). ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded. +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | --------------------------- | --------------------------------------------------------------------------------------- | -| 1 | CronJob metadata edits | Labels and annotations on the `CronJob` object | +| 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 | @@ -110,11 +89,13 @@ order they are recorded. | 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 mutation. -This means a single mutation can add a container and then configure it without selector resolution issues. +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`. @@ -131,8 +112,10 @@ m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { }) ``` -Note: no typed helper is provided for `spec.suspend`; it can be set via `Raw()` if needed, but suspension should -typically be handled via the framework's suspend mechanism. +!!! 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 @@ -160,15 +143,14 @@ Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `Ens ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.SetServiceAccountName("cleanup-sa") - e.Raw().RestartPolicy = corev1.RestartPolicyOnFailure return nil }) ``` ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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`. @@ -183,8 +165,8 @@ m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerE ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `CronJob` object itself, or -`m.EditPodTemplateMetadata` to target the pod template. +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`. @@ -197,8 +179,6 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Convenience Methods -The `Mutator` also exposes convenience wrappers that target all containers at once: - | Method | Equivalent to | | ----------------------------- | ------------------------------------------------------------- | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | @@ -206,57 +186,119 @@ The `Mutator` also exposes convenience wrappers that target all containers at on | `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | | `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | -## Operational Status - -The CronJob primitive reports operational status based on the CronJob's scheduling history: +## Workload-Kind-Agnostic Mutations -| Status | Condition | -| ------------------ | -------------------------------- | -| `OperationPending` | `Status.LastScheduleTime == nil` | -| `Operational` | `Status.LastScheduleTime != nil` | +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. -Failures are reported on the spawned Job resources, not on the CronJob itself. +See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern. -## Grace Status +## Operational Status -The default grace status handler always reports `Healthy`. A CronJob is a passive scheduler: once it exists and is not -suspended, it is functioning correctly regardless of whether it has fired yet. The schedule interval may be longer than -the grace period (e.g. monthly), so waiting for the first execution would produce false degradation signals. +`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 `WithCustomGraceStatus` if your CronJob has specific health requirements: +Override with `WithCustomOperationalStatus` if you need visibility into whether the CronJob has executed: ```go cronjob.NewBuilder(base). - WithCustomGraceStatus(func(cj *batchv1.CronJob) (concepts.GraceStatusWithReason, error) { - // Custom logic based on your CronJob's semantics - return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + 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 primitive sets `spec.suspend = true`. This prevents the CronJob controller -from creating new Job objects. Existing active jobs continue to run. +When the component is suspended, the CronJob sets `spec.suspend = true`, preventing new Jobs from being created. +Existing active jobs continue running. | Status | Condition | | ------------ | ---------------------------------------------------- | -| `Suspended` | `spec.suspend == true` and no active jobs | | `Suspending` | `spec.suspend == true` but active jobs still running | -| `Suspending` | Waiting for suspend flag to be applied | +| `Suspended` | `spec.suspend == true` and no active jobs | +| `Suspending` | Waiting for the suspend flag to be applied | -On unsuspend, the desired state (without `spec.suspend = true`) is applied via SSA, allowing the CronJob to resume -scheduling. +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. -The CronJob is never deleted on suspend (`DeleteOnSuspend = false`). +## 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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. +**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. -**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +**`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`. -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in -the same mutation resolve correctly. +**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/docs/primitives/daemonset.md b/docs/primitives/daemonset.md index 26eefe47..b2abf38e 100644 --- a/docs/primitives/daemonset.md +++ b/docs/primitives/daemonset.md @@ -1,17 +1,18 @@ # DaemonSet Primitive -The `daemonset` primitive is the framework's built-in workload abstraction for managing Kubernetes `DaemonSet` -resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, -pod specs, and metadata. +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 -| Capability | Detail | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `NumberReady`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` | -| **Graceful rollouts** | Reports rollout progress via `GraceStatus` for use with component-level grace periods (for example, configured with `WithGracePeriod`) | -| **Suspension** | Deletes the DaemonSet on suspend; reports `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, DaemonSet spec, pod spec, and containers | +| [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 @@ -28,7 +29,14 @@ base := &appsv1.DaemonSet{ MatchLabels: map[string]string{"app": "log-collector"}, }, Template: corev1.PodTemplateSpec{ - // baseline pod template + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "log-collector"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "collector"}, + }, + }, }, }, } @@ -40,31 +48,8 @@ resource, err := daemonset.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `DaemonSet` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: - -```go -func MyFeatureMutation(version string) daemonset.Mutation { - return daemonset.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *daemonset.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: +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 { @@ -74,7 +59,7 @@ func MonitoringMutation(version string, enabled bool) daemonset.Mutation { Mutate: func(m *daemonset.Mutator) error { m.EnsureContainer(corev1.Container{ Name: "metrics-exporter", - Image: "prom/node-exporter:v1.3.1", + Image: "prom/node-exporter:v1.8.0", }) return nil }, @@ -82,14 +67,17 @@ func MonitoringMutation(version string, enabled bool) daemonset.Mutation { } ``` +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 a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded. This ensures structural consistency across mutations. +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | --------------------------- | ----------------------------------------------------------------------- | -| 1 | DaemonSet metadata edits | Labels and annotations on the `DaemonSet` object | +| 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 | @@ -98,11 +86,13 @@ order they are recorded. This ensures structural consistency across mutations. | 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 mutation. -This means a single mutation can add a container and then configure it without selector resolution issues. +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`. @@ -117,7 +107,7 @@ m.EditDaemonSetSpec(func(e *editors.DaemonSetSpecEditor) error { }) ``` -For fields not covered by the typed API, use `Raw()`: +Use `Raw()` for fields the typed API does not cover: ```go m.EditDaemonSetSpec(func(e *editors.DaemonSetSpecEditor) error { @@ -151,8 +141,8 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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`. @@ -167,8 +157,8 @@ m.EditContainers(selectors.ContainerNamed("collector"), func(e *editors.Containe ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `DaemonSet` object itself, or -`m.EditPodTemplateMetadata` to target the pod template. +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`. @@ -179,15 +169,8 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { }) ``` -### Raw Escape Hatch - -All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is -insufficient. - ## Convenience Methods -The `Mutator` also exposes convenience wrappers that target all containers at once: - | Method | Equivalent to | | ----------------------------- | ------------------------------------------------------------- | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | @@ -195,54 +178,109 @@ The `Mutator` also exposes convenience wrappers that target all containers at on | `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 -- `DefaultSuspensionStatusHandler` always reports `Suspended` with reason `"DaemonSet deleted on suspend"` +- `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 required. +`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 (no matching nodes) and the controller has -observed the current generation (`ObservedGeneration >= Generation`), the DaemonSet is considered converged with the -reason "No nodes match the DaemonSet node selector". +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 nodes match the selector) | -| `Degraded` | `DesiredNumberScheduled == 0` but controller has not observed latest generation, or `DesiredNumberScheduled > 0 && NumberReady >= 1` but below desired | -| `Down` | `DesiredNumberScheduled > 0 && NumberReady == 0` | +| 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 state, not a +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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**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. -The internal ordering within each mutation handles intra-mutation dependencies automatically. +Internal ordering within each mutation handles intra-mutation dependencies automatically. -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in -the same mutation resolve correctly and reconciliation remains idempotent. +**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. - -**DaemonSets are node-scoped.** Unlike Deployments, DaemonSets run one pod per qualifying node. Use node selectors, -tolerations, and affinities in the pod spec to control which nodes run the DaemonSet pods. diff --git a/docs/primitives/deployment.md b/docs/primitives/deployment.md index cbd25c6e..8dbf06b1 100644 --- a/docs/primitives/deployment.md +++ b/docs/primitives/deployment.md @@ -1,17 +1,17 @@ # Deployment Primitive -The `deployment` primitive is the framework's built-in workload abstraction for managing Kubernetes `Deployment` -resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, -pod specs, and metadata. +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 -| Capability | Detail | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, `Scaling`, or `Failing` | -| **Graceful rollouts** | Detects stalled or failing rollouts via configurable grace periods | -| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, deployment spec, pod spec, and containers | +| [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 @@ -24,7 +24,13 @@ base := &appsv1.Deployment{ Namespace: owner.Namespace, }, Spec: appsv1.DeploymentSpec{ - // baseline spec + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web"}, + }, + }, + }, }, } @@ -35,97 +41,49 @@ resource, err := deployment.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Deployment` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: - -```go -func MyFeatureMutation(version string) deployment.Mutation { - return deployment.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *deployment.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func TracingMutation(version string, enabled bool) deployment.Mutation { - return deployment.Mutation{ - Name: "tracing", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *deployment.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - return nil - }, - } -} -``` - -### Version-gated mutations - -Pass a `[]feature.VersionConstraint` to gate on a semver range. `VersionConstraint` is an interface. Implement it using -the `github.com/Masterminds/semver/v3` library or any other mechanism: +Each mutation is a named `deployment.Mutation` that receives a `*deployment.Mutator` and records edits through typed +editors. ```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyAuthMutation(version string, enabled bool) deployment.Mutation { +func ConfigMutation(version string) deployment.Mutation { return deployment.Mutation{ - Name: "legacy-auth-header", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ).When(enabled), + Name: "config", + Feature: feature.NewVersionGate(version, nil), Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("api"), func(e *editors.ContainerEditor) error { - e.EnsureEnvVar(corev1.EnvVar{Name: "AUTH_HEADER", Value: "X-Legacy-Token"}) - return nil - }) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) return nil }, } } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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 a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded. This ensures structural consistency across mutations. +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | --------------------------- | ----------------------------------------------------------------------- | -| 1 | Deployment metadata edits | Labels and annotations on the `Deployment` object | +| 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.containers` | +| 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.initContainers` | +| 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 mutation. -This means a single mutation can add a container and then configure it without selector resolution issues. +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`. @@ -141,7 +99,7 @@ m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error { }) ``` -For fields not covered by the typed API (such as update strategy), use `Raw()`: +Use `Raw()` for fields the typed API does not cover, such as update strategy: ```go m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error { @@ -162,7 +120,7 @@ Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `Ens ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { - e.SetServiceAccountName("my-service-account") + e.SetServiceAccountName("web-sa") e.EnsureVolume(corev1.Volume{ Name: "config", VolumeSource: corev1.VolumeSource{ @@ -177,25 +135,24 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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("app"), func(e *editors.ContainerEditor) error { +m.EditContainers(selectors.ContainerNamed("web"), func(e *editors.ContainerEditor) error { e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) - e.EnsureArg("--metrics-port=9090") e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) return nil }) ``` -For fields not covered by the typed API (such as volume mounts), use `Raw()`: +For fields the typed API does not cover, such as volume mounts, use `Raw()`: ```go -m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { +m.EditContainers(selectors.ContainerNamed("web"), func(e *editors.ContainerEditor) error { e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ Name: "config", MountPath: "/etc/config", @@ -206,44 +163,24 @@ m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEdito ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Deployment` object itself, or -`m.EditPodTemplateMetadata` to target the pod template. +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 -// On the Deployment itself m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app.kubernetes.io/version", version) return nil }) - -// On the pod template m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureAnnotation("prometheus.io/scrape", "true") return nil }) ``` -### Raw Escape Hatch - -All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is -insufficient. The mutation remains scoped to the editor's target, so you cannot accidentally modify unrelated parts of -the spec. - -```go -m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { - e.Raw().SecurityContext = &corev1.SecurityContext{ - ReadOnlyRootFilesystem: ptr.To(true), - } - return nil -}) -``` - ## Convenience Methods -The `Mutator` also exposes convenience wrappers that target all containers at once: - | Method | Equivalent to | | ----------------------------- | ------------------------------------------------------------- | | `EnsureReplicas(n)` | `EditDeploymentSpec` → `SetReplicas(n)` | @@ -252,7 +189,30 @@ The `Mutator` also exposes convenience wrappers that target all containers at on | `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | | `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | -## Full Example: Adding a Sidecar +## 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 { @@ -260,16 +220,15 @@ func LoggingSidecarMutation(version string) deployment.Mutation { Name: "logging-sidecar", Feature: feature.NewVersionGate(version, nil), Mutate: func(m *deployment.Mutator) error { - // Step 1: ensure the sidecar exists (presence operation, step 5) + // Presence operation runs at step 5 m.EnsureContainer(corev1.Container{ Name: "logger", Image: "fluent/fluent-bit:3.0", }) - // Step 2: configure it (evaluated after step 1, step 6) + // 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"}) - // Volume mounts are not in the typed API, so use Raw() e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ Name: "varlog", MountPath: "/var/log", @@ -277,7 +236,7 @@ func LoggingSidecarMutation(version string) deployment.Mutation { return nil }) - // Step 3: add the shared volume to the pod spec (step 4, runs before containers) + // Pod spec edit runs at step 4 (before container presence) m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.EnsureVolume(corev1.Volume{ Name: "varlog", @@ -292,21 +251,25 @@ func LoggingSidecarMutation(version string) deployment.Mutation { } ``` -Note: although `EditPodSpec` is called after `EnsureContainer` in the source, it is applied in step 4 (before container +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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**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. -The internal ordering within each mutation handles intra-mutation dependencies automatically. +Internal ordering within each mutation handles intra-mutation dependencies automatically. -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in -the same mutation resolve correctly and reconciliation remains idempotent. +**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/docs/primitives/hpa.md b/docs/primitives/hpa.md index 7246fa87..1a26473a 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -1,18 +1,20 @@ -# HorizontalPodAutoscaler (HPA) Primitive +# HorizontalPodAutoscaler Primitive -The `hpa` primitive is the framework's built-in integration abstraction for managing Kubernetes -`HorizontalPodAutoscaler` resources (`autoscaling/v2`). It integrates with the component lifecycle as an Operational, -Graceful, Suspendable resource and provides a structured mutation API for configuring autoscaling behavior. +The `hpa` primitive wraps `autoscaling/v2 HorizontalPodAutoscaler` and integrates it with the component lifecycle as an +Operational, Graceful, and Suspendable resource. ## Capabilities -| Capability | Detail | -| ----------------------- | ------------------------------------------------------------------------------------------------------------- | -| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | -| **Grace status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Healthy`, `Degraded`, or `Down` | -| **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume | -| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | -| **Data extraction** | Allows custom extraction from the reconciled HPA object via a registered data extractor (`WithDataExtractor`) | +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 @@ -21,14 +23,14 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa" base := &autoscalingv2.HorizontalPodAutoscaler{ ObjectMeta: metav1.ObjectMeta{ - Name: "web-hpa", + Name: "backend-hpa", Namespace: owner.Namespace, }, Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ APIVersion: "apps/v1", Kind: "Deployment", - Name: "web", + Name: "backend", }, MinReplicas: ptr.To(int32(2)), MaxReplicas: 10, @@ -36,53 +38,34 @@ base := &autoscalingv2.HorizontalPodAutoscaler{ } resource, err := hpa.NewBuilder(base). - WithMutation(CPUMetricMutation(owner.Spec.Version)). + WithMutation(CPUScalingMutation(owner.Spec.Version)). Build() ``` ## Mutations -Mutations are the primary mechanism for modifying an HPA beyond its baseline. Each mutation is a named function that -receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +A concise version-gated example: ```go -func CPUMetricMutation(version string) hpa.Mutation { - return hpa.Mutation{ - Name: "cpu-metric", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *hpa.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations +var newScalingConstraint = semver.MustConstraint(">= 2.0.0") -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func CustomMetricsMutation(version string, enabled bool) hpa.Mutation { +func AggressiveScalingMutation(version string, enabled bool) hpa.Mutation { return hpa.Mutation{ - Name: "custom-metrics", - Feature: feature.NewVersionGate(version, nil).When(enabled), + 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.EnsureMetric(autoscalingv2.MetricSpec{ - Type: autoscalingv2.PodsMetricSourceType, - Pods: &autoscalingv2.PodsMetricSource{ - Metric: autoscalingv2.MetricIdentifier{Name: "requests_per_second"}, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.AverageValueMetricType, - AverageValue: ptr.To(resource.MustParse("100")), - }, + e.SetMaxReplicas(20) + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(60)), }, }) return nil @@ -93,48 +76,26 @@ func CustomMetricsMutation(version string, enabled bool) hpa.Mutation { } ``` -### Version-gated mutations - -Pass a `[]feature.VersionConstraint` to gate on a semver range: - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyScalingMutation(version string) hpa.Mutation { - return hpa.Mutation{ - Name: "legacy-scaling", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *hpa.Mutator) error { - m.EditHPASpec(func(e *editors.HPASpecEditor) error { - e.SetMaxReplicas(5) // legacy apps limited to 5 replicas - return nil - }) - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded: +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 HPA-level settings via `m.EditHPASpec`. +Controls the HPA spec via `m.EditHPASpec`. Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`, `SetBehavior`, `Raw`. @@ -157,30 +118,29 @@ m.EditHPASpec(func(e *editors.HPASpecEditor) error { }) ``` -#### EnsureMetric - -`EnsureMetric` upserts a metric based on its full metric identity, not just type and name. Matching rules: +#### EnsureMetric identity rules -| Metric type | Match key | -| ----------------- | --------------------------------------------------------------------------------------------------------- | -| Resource | `Resource.Name` (e.g. `cpu`, `memory`) | -| Pods | `Pods.Metric.Name` + `Pods.Metric.Selector` (label 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` (label selector; `nil` is a distinct identity) | +`EnsureMetric` upserts by full metric identity. If a matching entry exists it is replaced; otherwise the metric is +appended. -If a matching entry exists it is replaced; otherwise the metric is appended. Be aware that different selectors or -described objects result in different metric identities, even if the metric names are the same. +| 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. +`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 use Kubernetes defaults. +behavior and revert to Kubernetes defaults. ```go m.EditHPASpec(func(e *editors.HPASpecEditor) error { @@ -210,29 +170,23 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno ```go m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator") - e.EnsureAnnotation("autoscaling.example.io/policy", "aggressive") + e.EnsureLabel("app.kubernetes.io/version", version) return nil }) ``` -### Raw Escape Hatch - -All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is -insufficient. - ## Operational Status -The default operational status handler inspects `Status.Conditions`: +The default handler inspects `Status.Conditions`: -| Status | Condition | -| ------------- | ------------------------------------------------------- | -| `Operational` | `ScalingActive` is `True` | -| `Pending` | Conditions absent, or `ScalingActive` is `Unknown` | -| `Failing` | `ScalingActive` is `False`, or `AbleToScale` is `False` | +| 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 actually scale is not -operationally healthy regardless of what the scaling-active condition reports. +`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`: @@ -250,7 +204,7 @@ hpa.NewBuilder(base). ## Grace Status -The default grace status handler inspects `Status.Conditions` to assess health after the grace period expires: +The default grace handler applies the same condition inspection after the grace period expires: | Status | Condition | | ---------- | ------------------------------------------------------- | @@ -258,9 +212,6 @@ The default grace status handler inspects `Status.Conditions` to assess health a | `Degraded` | Conditions absent, or `ScalingActive` is `Unknown` | | `Down` | `ScalingActive` is `False`, or `AbleToScale` is `False` | -`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not -healthy regardless of what the scaling-active condition reports. - Override with `WithCustomGraceStatus`: ```go @@ -277,27 +228,46 @@ hpa.NewBuilder(base). ## Suspension -HPA has no native suspend field. The default behavior is **delete on suspend**: the HPA is removed when the component is -suspended (`DefaultDeleteOnSuspendHandler` returns `true`). A retained HPA would conflict with the suspension of its -scale target (e.g. a Deployment scaled to zero) because the Kubernetes HPA controller continuously enforces -`minReplicas` and would scale the target back up. Deleting the HPA prevents this interference. On resume the framework -recreates the HPA with the desired spec. +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 default suspension status handler reports `Suspended` immediately with the reason -`"HorizontalPodAutoscaler suspended to prevent scaling interference"`. Override this handler with -`WithCustomSuspendStatus` if you need a reason that reflects custom deletion behaviour. +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. -Override with `WithCustomSuspendDeletionDecision` if you want to retain the HPA during suspension (e.g. when the scale -target is managed externally and will not be present during suspension): +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 HPA during suspension + return false // keep the HPA during suspension }) ``` -## Full Example: CPU and Memory Autoscaling +!!! 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 { @@ -309,7 +279,7 @@ func AutoscalingMutation(version string) hpa.Mutation { e.SetMinReplicas(ptr.To(int32(2))) e.SetMaxReplicas(10) - // CPU-based scaling + // CPU-based scaling target e.EnsureMetric(autoscalingv2.MetricSpec{ Type: autoscalingv2.ResourceMetricSourceType, Resource: &autoscalingv2.ResourceMetricSource{ @@ -321,7 +291,7 @@ func AutoscalingMutation(version string) hpa.Mutation { }, }) - // Memory-based scaling + // Memory-based scaling target e.EnsureMetric(autoscalingv2.MetricSpec{ Type: autoscalingv2.ResourceMetricSourceType, Resource: &autoscalingv2.ResourceMetricSource{ @@ -333,7 +303,7 @@ func AutoscalingMutation(version string) hpa.Mutation { }, }) - // Conservative scale-down + // Conservative scale-down to avoid thrashing e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ ScaleDown: &autoscalingv2.HPAScalingRules{ StabilizationWindowSeconds: ptr.To(int32(300)), @@ -352,24 +322,29 @@ func AutoscalingMutation(version string) hpa.Mutation { }, } } + +resource, err := hpa.NewBuilder(base). + WithMutation(AutoscalingMutation(owner.Spec.Version)). + Build() ``` -Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, metadata edits are applied first per -the internal ordering. Order your source calls for readability; the framework handles execution order. +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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**`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. -**Register mutations in dependency order.** If mutation B relies on a metric added 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. -**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name, -selector, and described object where applicable), 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. -**HPA deletion on suspend is the default.** The primitive's default `DeleteOnSuspend` decision removes the HPA during -component suspension (matching the "Suspension (delete)" capability). This prevents the Kubernetes HPA controller from -scaling the target back up while it is suspended. On resume the framework recreates the HPA with the desired spec. If -you need the HPA to be retained during suspension (for example, when the scale target is managed externally and will not -be present), override `WithCustomSuspendDeletionDecision` to return `false`. +**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/docs/primitives/ingress.md b/docs/primitives/ingress.md index c5319a5a..6ccd685a 100644 --- a/docs/primitives/ingress.md +++ b/docs/primitives/ingress.md @@ -1,18 +1,21 @@ # Ingress Primitive -The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. -It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a structured -mutation API for managing rules, TLS configuration, and metadata. For an overview of all built-in primitives, see -[Primitives](../primitives.md). +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 status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | -| **Grace status** | Reports `Degraded` until a load balancer IP or hostname is assigned, then `Healthy` | -| **Suspension** | No-op by default. Ingress is left in place; backend returns 502/503 | -| **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | +| 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 @@ -28,7 +31,7 @@ base := &networkingv1.Ingress{ IngressClassName: ptr.To("nginx"), Rules: []networkingv1.IngressRule{ { - Host: "example.com", + Host: "app.example.com", IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ @@ -57,31 +60,12 @@ resource, err := ingress.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: - -```go -func MyFeatureMutation(version string) ingress.Mutation { - return ingress.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *ingress.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: +A kind-specific example gating a TLS mutation on a boolean condition: ```go func TLSMutation(version string, enabled bool) ingress.Mutation { @@ -91,7 +75,7 @@ func TLSMutation(version string, enabled bool) ingress.Mutation { Mutate: func(m *ingress.Mutator) error { m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.EnsureTLS(networkingv1.IngressTLS{ - Hosts: []string{"example.com"}, + Hosts: []string{"app.example.com"}, SecretName: "tls-cert", }) return nil @@ -102,23 +86,22 @@ func TLSMutation(version string, enabled bool) ingress.Mutation { } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 are applied in their registration order. Later features observe the Ingress as modified by -all previous features. +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: @@ -126,9 +109,9 @@ The primary API for modifying the Ingress spec. Use `m.EditIngressSpec` for full ```go m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.SetIngressClassName("nginx") - e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + e.EnsureRule(networkingv1.IngressRule{Host: "app.example.com"}) e.EnsureTLS(networkingv1.IngressTLS{ - Hosts: []string{"example.com"}, + Hosts: []string{"app.example.com"}, SecretName: "tls-cert", }) return nil @@ -182,7 +165,7 @@ matches any of the provided hosts. ```go m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.EnsureTLS(networkingv1.IngressTLS{ - Hosts: []string{"example.com", "www.example.com"}, + Hosts: []string{"app.example.com", "www.example.com"}, SecretName: "wildcard-tls", }) e.RemoveTLS("old.example.com") @@ -192,7 +175,7 @@ m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { #### Raw Escape Hatch -`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access when the typed API is insufficient: +`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access: ```go m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { @@ -217,30 +200,25 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Operational Status -The Ingress primitive uses the **Integration** lifecycle, which implements `concepts.Operational` instead of -`concepts.Alive`. +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`: -### DefaultOperationalStatusHandler +| Condition | Status | +| ----------------------------------------- | ------------------ | +| Entry with `IP != ""` or `Hostname != ""` | `Operational` | +| Otherwise | `OperationPending` | -| Condition | Status | Reason | -| ----------------------------------------- | ------------------ | ----------------------------------------- | -| Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address | -| Otherwise | `OperationPending` | Awaiting load balancer address assignment | - -The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or -`Hostname` to report operational. - -Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by -cloud providers). +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` to assess health after the grace period expires: +The default grace status handler inspects `Status.LoadBalancer.Ingress` after the grace period expires: -| Status | Condition | -| ---------- | -------------------------------------------------------- | -| `Healthy` | At least one entry with a non-empty `IP` or `Hostname` | -| `Degraded` | No entries, or all entries lack both `IP` and `Hostname` | +| 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`: @@ -258,18 +236,18 @@ ingress.NewBuilder(base). ## Suspension -### Default Behaviour +### Default Behavior -The default suspension strategy is a **no-op**: +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 (e.g. nginx) 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 behaviour. +**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 @@ -283,13 +261,76 @@ resource, err := ingress.NewBuilder(base). 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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**`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/docs/primitives/job.md b/docs/primitives/job.md index 578ea304..2e40c4d1 100644 --- a/docs/primitives/job.md +++ b/docs/primitives/job.md @@ -1,16 +1,16 @@ # Job Primitive -The `job` primitive is the framework's built-in task abstraction for managing Kubernetes `Job` resources. It integrates -fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata, -following the same pod-template mutation pattern as the Deployment 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 -| Capability | Detail | -| ----------------------- | ----------------------------------------------------------------------------------------------- | -| **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` | -| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers | +| [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 @@ -27,7 +27,7 @@ base := &batchv1.Job{ Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyOnFailure, Containers: []corev1.Container{ - {Name: "migrate", Image: "my-app-migration:latest"}, + {Name: "migrate", Image: "migration-tool:latest"}, }, }, }, @@ -41,65 +41,16 @@ resource, err := job.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Job` beyond its baseline. Each mutation is a named function that -receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +Each mutation is a named `job.Mutation` that receives a `*job.Mutator` and records edits through typed editors. ```go -func MyFeatureMutation(version string) job.Mutation { +func MigrationConfigMutation(version string) job.Mutation { return job.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *job.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func TracingMutation(version string, enabled bool) job.Mutation { - return job.Mutation{ - Name: "tracing", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *job.Mutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "OTEL_EXPORTER_OTLP_ENDPOINT", - Value: "http://otel-collector:4317", - }) - return nil - }, - } -} -``` - -### Version-gated mutations - -Pass a `[]feature.VersionConstraint` to gate on a semver range: - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyMigrationMutation(version string) job.Mutation { - return job.Mutation{ - Name: "legacy-migration-format", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), + 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: "MIGRATION_FORMAT", Value: "v1"}) + e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "db:5432"}) return nil }) return nil @@ -108,16 +59,17 @@ func LegacyMigrationMutation(version string) job.Mutation { } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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 a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded. This ensures structural consistency across mutations. +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | --------------------------- | ----------------------------------------------------------------------- | -| 1 | Job metadata edits | Labels and annotations on the `Job` object | +| 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 | @@ -126,11 +78,13 @@ order they are recorded. This ensures structural consistency across mutations. | 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 mutation. -This means a single mutation can add a container and then configure it without selector resolution issues. +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`. @@ -146,7 +100,7 @@ m.EditJobSpec(func(e *editors.JobSpecEditor) error { }) ``` -For fields not covered by the typed API, use `Raw()`: +Use `Raw()` for fields the typed API does not cover: ```go m.EditJobSpec(func(e *editors.JobSpecEditor) error { @@ -180,15 +134,15 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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: "postgres:5432"}) + e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "db:5432"}) e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) return nil }) @@ -196,8 +150,8 @@ m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerE ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Job` object itself, or -`m.EditPodTemplateMetadata` to target the pod template. +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`. @@ -210,47 +164,87 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Convenience Methods -The `Mutator` exposes convenience wrappers that target all containers at once: - | 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 Task lifecycle for suspension, which differs from Workloads: +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` checks if `spec.suspend=true` and `status.active=0`. +- **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 of these via the Builder: +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 + 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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**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. -The internal ordering within each mutation handles intra-mutation dependencies automatically. - -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in -the same mutation resolve correctly and reconciliation remains idempotent. +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. - -**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension. -Override `WithCustomSuspendDeletionDecision` if you need to keep the Job resource in the cluster. diff --git a/docs/primitives/networkpolicy.md b/docs/primitives/networkpolicy.md index c7895d70..b49dfa9e 100644 --- a/docs/primitives/networkpolicy.md +++ b/docs/primitives/networkpolicy.md @@ -1,17 +1,19 @@ # NetworkPolicy Primitive -The `networkpolicy` primitive is the framework's built-in static abstraction for managing Kubernetes `NetworkPolicy` -resources. It integrates with the component lifecycle and provides a structured mutation API for managing pod selectors, -ingress rules, egress rules, and policy types. +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 for free-form access | -| **Append semantics** | Ingress and egress rules have no unique key. `AppendIngressRule`/`AppendEgressRule` append unconditionally | -| **Data extraction** | Reads generated or updated values back from the reconciled NetworkPolicy after each sync cycle | +| **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 @@ -41,11 +43,12 @@ resource, err := networkpolicy.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `NetworkPolicy` beyond its baseline. Each mutation is a named -function that receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. Prefer this -for mutations that should always run and do not need feature-gate evaluation: +A kind-specific example appending an ingress rule unconditionally: ```go func HTTPIngressMutation() networkpolicy.Mutation { @@ -69,75 +72,22 @@ func HTTPIngressMutation() networkpolicy.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -```go -func MetricsIngressMutation(version string, enableMetrics bool) networkpolicy.Mutation { - return networkpolicy.Mutation{ - Name: "metrics-ingress", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - 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 - }, - } -} -``` - -### Version-gated mutations - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyNetworkPolicyMutation(version string) networkpolicy.Mutation { - return networkpolicy.Mutation{ - Name: "legacy-policy", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *networkpolicy.Mutator) error { - m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { - e.SetPolicyTypes([]networkingv1.PolicyType{ - networkingv1.PolicyTypeIngress, - }) - return nil - }) - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 are applied in their registration order. Later features observe the NetworkPolicy as -modified by all previous features. +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: @@ -190,8 +140,7 @@ Sets the policy types. Valid values are `networkingv1.PolicyTypeIngress` and `ne #### Raw Escape Hatch -`Raw()` returns the underlying `*networkingv1.NetworkPolicySpec` for free-form editing when none of the structured -methods are sufficient: +`Raw()` returns the underlying `*networkingv1.NetworkPolicySpec` for free-form editing: ```go m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { @@ -218,7 +167,23 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { }) ``` -## Full Example: Feature-Composed Network Policy +## 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 { @@ -266,14 +231,13 @@ resource, err := networkpolicy.NewBuilder(base). Build() ``` -When `EnableMetrics` is true, the final NetworkPolicy will have both HTTP and metrics ingress rules. When false, only -the HTTP rule is present. Neither mutation needs to know about the other. +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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**`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. @@ -282,3 +246,6 @@ 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/docs/primitives/pdb.md b/docs/primitives/pdb.md index 46a31d3c..ceab4477 100644 --- a/docs/primitives/pdb.md +++ b/docs/primitives/pdb.md @@ -1,16 +1,24 @@ # PodDisruptionBudget Primitive -The `pdb` primitive is the framework's built-in static abstraction for managing Kubernetes `PodDisruptionBudget` -resources. It integrates with the component lifecycle and provides a structured mutation API for managing disruption -policies and object metadata. +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 -| Capability | Detail | -| --------------------- | --------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | -| **Mutation pipeline** | Typed editors for PDB spec and object metadata, with a raw escape hatch for free-form access | -| **Data extraction** | Reads generated or updated values back from the reconciled PDB after each sync cycle | +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 @@ -20,53 +28,37 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pdb" minAvailable := intstr.FromString("50%") base := &policyv1.PodDisruptionBudget{ ObjectMeta: metav1.ObjectMeta{ - Name: "web-server-pdb", + Name: "backend-pdb", Namespace: owner.Namespace, }, Spec: policyv1.PodDisruptionBudgetSpec{ MinAvailable: &minAvailable, Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "web-server"}, + MatchLabels: map[string]string{"app": "backend"}, }, }, } resource, err := pdb.NewBuilder(base). - WithMutation(MyFeatureMutation(owner.Spec.Version)). + WithMutation(DisruptionPolicyMutation(owner.Spec.Version)). Build() ``` ## Mutations -Mutations are the primary mechanism for modifying a `PodDisruptionBudget` beyond its baseline. Each mutation is a named -function that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: - -```go -func MyFeatureMutation(version string) pdb.Mutation { - return pdb.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *pdb.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. +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). -### Boolean-gated mutations +A concise boolean-gated example that switches from percentage-based `MinAvailable` to absolute `MaxUnavailable`: ```go -func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation { +func StrictAvailabilityMutation(version string, strict bool) pdb.Mutation { return pdb.Mutation{ Name: "strict-availability", - Feature: feature.NewVersionGate(version, nil).When(enabled), + Feature: feature.NewVersionGate(version, nil).When(strict), Mutate: func(m *pdb.Mutator) error { m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { e.ClearMinAvailable() @@ -79,55 +71,35 @@ func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation { } ``` -### Version-gated mutations - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyPDBMutation(version string) pdb.Mutation { - return pdb.Mutation{ - Name: "legacy-pdb", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *pdb.Mutator) error { - m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { - e.SetMinAvailable(intstr.FromInt32(1)) - return nil - }) - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 | -Within each category, edits are applied in their registration order. Later features observe the PodDisruptionBudget as -modified by all previous features. +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. Use `m.EditSpec` for full control: +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": "web"}, + MatchLabels: map[string]string{"app": "backend"}, }) return nil }) @@ -135,12 +107,8 @@ m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { #### SetMinAvailable and SetMaxUnavailable -`SetMinAvailable` sets the minimum number of pods that must remain available during a disruption. `SetMaxUnavailable` -sets the maximum number of pods that can be unavailable. Both accept `intstr.IntOrString`, either an integer count or a -percentage string (e.g. `"50%"`). - -These fields are mutually exclusive in the Kubernetes API. Use `ClearMinAvailable` or `ClearMaxUnavailable` to remove -the opposing constraint when switching between them: +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 { @@ -150,24 +118,10 @@ m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { }) ``` -#### SetSelector - -`SetSelector` replaces the pod selector used by the PDB: - -```go -m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { - e.SetSelector(&metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "web", "tier": "frontend"}, - }) - return nil -}) -``` - #### SetUnhealthyPodEvictionPolicy -`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: +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 { @@ -176,9 +130,7 @@ m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { }) ``` -#### Raw Escape Hatch - -`Raw()` returns the underlying `*policyv1.PodDisruptionBudgetSpec` for direct access when the typed API is insufficient: +For fields not covered by the typed API, use `Raw()`: ```go m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { @@ -196,12 +148,25 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno ```go m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app.kubernetes.io/version", version) - e.EnsureAnnotation("pdb.example.io/policy", "strict") return nil }) ``` -## Full Example: Feature-Gated Disruption Policy +## 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 { @@ -218,10 +183,10 @@ func BasePDBMutation(version string) pdb.Mutation { } } -func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation { +func StrictAvailabilityMutation(version string, strict bool) pdb.Mutation { return pdb.Mutation{ Name: "strict-availability", - Feature: feature.NewVersionGate(version, nil).When(enabled), + Feature: feature.NewVersionGate(version, nil).When(strict), Mutate: func(m *pdb.Mutator) error { m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { e.ClearMinAvailable() @@ -233,23 +198,44 @@ func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation { } } +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 +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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**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/docs/primitives/pod.md b/docs/primitives/pod.md index 741000f5..63c855e7 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -1,20 +1,21 @@ # Pod Primitive -The `pod` primitive is the framework's built-in workload abstraction for managing Kubernetes `Pod` resources directly. -It integrates fully with the component lifecycle and provides a mutation API for managing containers, pod specs, and -metadata. +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. -Pods are rarely managed directly by operators; this primitive is provided for completeness and for operators that manage -pod objects (e.g. debugging utilities, node-local agents). +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 -| Capability | Detail | -| --------------------- | -------------------------------------------------------------------------------------------------- | -| **Health tracking** | Monitors pod phase and container statuses; reports `Healthy`, `Creating`, `Updating`, or `Failing` | -| **Graceful rollouts** | Detects degraded or down states via grace status handler | -| **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, pod spec, and containers | +| [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 @@ -23,15 +24,12 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" base := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "debug-pod", + Name: "agent", Namespace: owner.Namespace, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ - { - Name: "debug", - Image: "busybox:latest", - }, + {Name: "agent", Image: "agent:latest"}, }, }, } @@ -43,49 +41,28 @@ resource, err := pod.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Pod` beyond its baseline. Each mutation is a named function that -receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +Each mutation is a named `pod.Mutation` that receives a `*pod.Mutator` and records edits through typed editors. ```go -func MyFeatureMutation(version string) pod.Mutation { +func AgentConfigMutation(version string, debug bool) pod.Mutation { return pod.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled + Name: "agent-config", + Feature: feature.NewVersionGate(version, nil).When(debug), Mutate: func(m *pod.Mutator) error { - // record edits here + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) return nil }, } } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func DebugMutation(version string, enabled bool) pod.Mutation { - return pod.Mutation{ - Name: "debug-mode", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *pod.Mutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "DEBUG", Value: "true"}) - 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 a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded. This ensures structural consistency across mutations. +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | -------------------------- | ----------------------------------------------------------------------- | @@ -96,37 +73,37 @@ order they are recorded. This ensures structural consistency across mutations. | 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 mutation. -This means a single mutation can add a container and then configure it without selector resolution issues. +Container edits (steps 4 and 6) are evaluated against a snapshot taken _after_ presence operations in the same feature. -**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall -structure of `spec.containers` and `spec.initContainers` and the majority of per-container fields (such as `env`, -`args`, resources, ports, and probes). Presence operations such as `EnsureContainer` / `RemoveContainer` (and the -corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, -not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the -Kubernetes API server will reject the update. In practice, the set of fields that can be updated in-place on an existing -Pod is very small (primarily container images, plus a few feature-gated fields such as resources with in-place resize); -treat Pods as effectively immutable and use delete-and-recreate when you need to change other container attributes. +!!! 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`. `RemoveTolerations` accepts a predicate -function (`match func(corev1.Toleration) bool`) and removes all tolerations for which `match` returns `true`. +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { - e.SetServiceAccountName("my-service-account") + e.SetServiceAccountName("agent-sa") e.EnsureVolume(corev1.Volume{ Name: "config", VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "app-config"}, + LocalObjectReference: corev1.LocalObjectReference{Name: "agent-config"}, }, }, }) @@ -136,28 +113,27 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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("app"), func(e *editors.ContainerEditor) error { +m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error { e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) - e.EnsureArg("--metrics-port=9090") e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) return nil }) ``` -For fields not covered by the typed API (such as volume mounts), use `Raw()`: +For fields the typed API does not cover, such as volume mounts, use `Raw()`: ```go -m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { +m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error { e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ Name: "config", - MountPath: "/etc/config", + MountPath: "/etc/agent", }) return nil }) @@ -176,25 +152,8 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { }) ``` -### Raw Escape Hatch - -All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is -insufficient. The mutation remains scoped to the editor's target, so you cannot accidentally modify unrelated parts of -the spec. - -```go -m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { - e.Raw().SecurityContext = &corev1.SecurityContext{ - ReadOnlyRootFilesystem: ptr.To(true), - } - return nil -}) -``` - ## Convenience Methods -The `Mutator` also exposes convenience wrappers that target all containers at once: - | Method | Equivalent to | | ----------------------------- | ------------------------------------------------------------- | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | @@ -206,21 +165,61 @@ The `Mutator` also exposes convenience wrappers that target all containers at on 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`: no-op (deletion is handled by the framework). -- `DefaultSuspensionStatusHandler`: always returns `{Suspended, "Pod deleted on suspend"}`. +- `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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**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. -The internal ordering within each mutation handles intra-mutation dependencies automatically. - -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in -the same mutation resolve correctly and reconciliation remains idempotent. +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/docs/primitives/pv.md b/docs/primitives/pv.md index d92e6be2..cbc054ce 100644 --- a/docs/primitives/pv.md +++ b/docs/primitives/pv.md @@ -1,18 +1,21 @@ # PersistentVolume Primitive -The `pv` primitive is the framework's built-in integration abstraction for managing Kubernetes `PersistentVolume` -resources. It integrates with the component lifecycle as an Operational, Graceful resource and provides a structured -mutation API for managing PV spec fields and object metadata. +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 | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Integration lifecycle** | Reports `concepts.OperationalStatusOperational`, `concepts.OperationalStatusPending`, or `concepts.OperationalStatusFailing` based on the PV's phase | -| **Grace status** | Maps PV phase to grace status: 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 | -| **Mutation pipeline** | Typed editors for PV spec fields and object metadata, with a raw escape hatch for free-form access | -| **Data extraction** | Reads generated or updated values back from the reconciled PersistentVolume after each sync cycle | +| 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 @@ -42,34 +45,17 @@ resource, err := pv.NewBuilder(base). Build() ``` -PersistentVolumes are cluster-scoped. The builder validates that Name is set and that Namespace is empty. Setting a -namespace on the PV object will cause `Build()` to return an error. +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 -Mutations are the primary mechanism for modifying a `PersistentVolume` beyond its baseline. Each mutation is a named -function that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: - -```go -func MyFeatureMutation(version string) pv.Mutation { - return pv.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *pv.Mutator) error { - m.SetStorageClassName("fast-ssd") - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. +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). -### Boolean-gated mutations +A kind-specific example using the `SetStorageClassName` convenience method: ```go func RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation { @@ -84,43 +70,22 @@ func RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation { } ``` -### Version-gated mutations - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyStorageClassMutation(version string) pv.Mutation { - return pv.Mutation{ - Name: "legacy-storage-class", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *pv.Mutator) error { - m.SetStorageClassName("legacy-hdd") - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 are applied in their registration order. Later features observe the PersistentVolume as -modified by all previous features. +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: @@ -149,8 +114,7 @@ m.EditPVSpec(func(e *editors.PVSpecEditor) error { #### Raw escape hatch -`Raw()` returns the underlying `*corev1.PersistentVolumeSpec` for free-form editing when none of the structured methods -are sufficient: +`Raw()` returns the underlying `*corev1.PersistentVolumeSpec` for free-form editing: ```go m.EditPVSpec(func(e *editors.PVSpecEditor) error { @@ -188,16 +152,15 @@ single edit block. ## Operational Status -The PV primitive uses the Integration lifecycle. The default operational status handler maps PV phases to framework -status: +The PV primitive implements `concepts.Operational`. The default handler maps PV phase to operational status: -| PV Phase | Operational Status | Meaning | -| --------- | ---------------------------- | -------------------------------------- | -| Available | OperationalStatusOperational | PV is ready for binding | -| Bound | OperationalStatusOperational | PV is bound to a PersistentVolumeClaim | -| Pending | OperationalStatusPending | PV is waiting to become available | -| Released | OperationalStatusFailing | PV was released, not yet reclaimed | -| Failed | OperationalStatusFailing | PV reclamation has failed | +| 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. @@ -227,7 +190,7 @@ pv.NewBuilder(base). }) ``` -## Full Example: Storage-Tier PersistentVolume +## Full Example ```go func StorageClassMutation(version string) pv.Mutation { @@ -267,20 +230,15 @@ resource, err := pv.NewBuilder(base). **PersistentVolumes are cluster-scoped.** Do not set a namespace on the PV object. The builder rejects namespaced PVs with a clear error. -**Use the Integration lifecycle for status.** PVs report `OperationalStatusOperational`, `OperationalStatusPending`, or -`OperationalStatusFailing` based on their phase. Override with `WithCustomOperationalStatus` only when phase-based -readiness is insufficient. - -**Controller references and garbage collection.** The component reconciliation pipeline attempts to set a controller +**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 and the PV is cluster-scoped, the framework detects this mismatch and -**skips setting `ownerReferences`** (logging an informational message) instead of letting the API server reject the -request. As a result, such PVs will **not** be garbage collected automatically when the owning component is deleted. If -you need garbage collection for PVs, either: - -- Model the PV as owned by a dedicated **cluster-scoped** controller/component so a valid controller reference can be - set, or -- Accept that PVs managed from a **namespace-scoped** component will not have `ownerReferences` and handle their - lifecycle explicitly (for example, by deleting them in custom logic when appropriate). +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/docs/primitives/pvc.md b/docs/primitives/pvc.md index fcc0f489..777ce034 100644 --- a/docs/primitives/pvc.md +++ b/docs/primitives/pvc.md @@ -1,18 +1,21 @@ # PersistentVolumeClaim Primitive -The `pvc` primitive is the framework's built-in integration abstraction for managing Kubernetes `PersistentVolumeClaim` -resources. It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a -structured mutation API for managing storage requests and object metadata. +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 tracking** | Monitors PVC phase. Reports `OperationalStatusOperational` (Bound), `OperationalStatusPending`, or `OperationalStatusFailing` (Lost) | -| **Grace status** | Bound is `Healthy`, Lost is `Down`, any other phase is `Degraded` | -| **Suspension** | PVCs are immediately suspended (no runtime state to wind down); data is preserved by default | -| **Mutation pipeline** | Typed editors for PVC spec and object metadata, with a raw escape hatch for free-form access | -| **Data extraction** | Reads bound volume name, capacity, or other status fields after each sync cycle | +| 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 @@ -41,17 +44,18 @@ resource, err := pvc.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `PersistentVolumeClaim` beyond its baseline. Each mutation is a -named function that receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +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), // always enabled + Feature: feature.NewVersionGate(version, nil), Mutate: func(m *pvc.Mutator) error { m.SetStorageRequest(resource.MustParse("20Gi")) return nil @@ -60,62 +64,21 @@ func MyStorageMutation(version string) pvc.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -```go -func LargeStorageMutation(version string, needsLargeStorage bool) pvc.Mutation { - return pvc.Mutation{ - Name: "large-storage", - Feature: feature.NewVersionGate(version, nil).When(needsLargeStorage), - Mutate: func(m *pvc.Mutator) error { - m.SetStorageRequest(resource.MustParse("100Gi")) - return nil - }, - } -} -``` - -### Version-gated mutations - -```go -var v2Constraint = mustSemverConstraint(">= 2.0.0") - -func V2StorageMutation(version string) pvc.Mutation { - return pvc.Mutation{ - Name: "v2-storage", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{v2Constraint}, - ), - Mutate: func(m *pvc.Mutator) error { - m.SetStorageRequest(resource.MustParse("50Gi")) - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 are applied in their registration order. The PVC primitive groups mutations by feature -boundary: for each applicable feature (after evaluating version constraints and any `When()` conditions), all of its -planned edits are applied in order, and later features and mutations observe the fully-applied state from earlier ones. +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: @@ -140,8 +103,7 @@ Available methods: #### Raw Escape Hatch -`Raw()` returns the underlying `*corev1.PersistentVolumeClaimSpec` for free-form editing when none of the structured -methods are sufficient: +`Raw()` returns the underlying `*corev1.PersistentVolumeClaimSpec` for free-form editing: ```go m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { @@ -178,31 +140,17 @@ The `Mutator` exposes a convenience wrapper for the most common PVC operation: Use this for simple, single-operation mutations. Use `EditPVCSpec` when you need multiple operations or raw access in a single edit block. -## Status Handlers - -### Operational Status +## Operational Status -The default handler (`DefaultOperationalStatusHandler`) maps PVC phase to operational status: +The PVC primitive implements `concepts.Operational`. The default handler maps PVC phase to operational status: -| PVC Phase | Status | Reason | -| --------- | ------------------------------ | ------------------------------- | -| `Bound` | `OperationalStatusOperational` | PVC is bound to volume \ | -| `Pending` | `OperationalStatusPending` | Waiting for PVC to be bound | -| `Lost` | `OperationalStatusFailing` | PVC has lost its bound volume | +| 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 (e.g. verifying specific annotations or volume -attributes). - -### Suspension - -PVCs have no runtime state to wind down, so: - -- `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. +Override with `WithCustomOperationalStatus` for additional checks. ## Grace Status @@ -228,11 +176,81 @@ pvc.NewBuilder(base). }) ``` +## 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 mutations for storage expansion carefully.** Kubernetes only allows expanding PVC storage (not shrinking). -Ensure your mutations respect this constraint. The `SetStorageRequest` method does not enforce this; the API server will -reject invalid requests. +**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/docs/primitives/replicaset.md b/docs/primitives/replicaset.md index bb9c7a9e..2d7cb9b2 100644 --- a/docs/primitives/replicaset.md +++ b/docs/primitives/replicaset.md @@ -1,19 +1,21 @@ # ReplicaSet Primitive -The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It -integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and -metadata. +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; operators typically use Deployments. This primitive is provided for operators -that own ReplicaSets explicitly (e.g. custom rollout controllers). +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 -| Capability | Detail | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` | -| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers | +| [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 @@ -29,7 +31,16 @@ base := &appsv1.ReplicaSet{ Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{"app": "worker"}, }, - // baseline spec + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "worker"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "worker"}, + }, + }, + }, }, } @@ -40,51 +51,29 @@ resource, err := replicaset.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +Each mutation is a named `replicaset.Mutation` that receives a `*replicaset.Mutator` and records edits through typed +editors. ```go -func MyFeatureMutation(version string) replicaset.Mutation { +func WorkerConfigMutation(version string) replicaset.Mutation { return replicaset.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled + Name: "worker-config", + Feature: feature.NewVersionGate(version, nil), Mutate: func(m *replicaset.Mutator) error { - // record edits here + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "WORKER_THREADS", Value: "4"}) return nil }, } } ``` -Mutations are applied in the order they are registered with the builder. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func TracingMutation(version string, enabled bool) replicaset.Mutation { - return replicaset.Mutation{ - Name: "tracing", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *replicaset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - 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 a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded: +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | --------------------------- | ----------------------------------------------------------------------- | @@ -97,10 +86,13 @@ order they are recorded: | 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 mutation. +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`. @@ -115,8 +107,10 @@ m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { }) ``` -Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object -passed to `NewBuilder`. +!!! 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 @@ -128,30 +122,31 @@ Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `Ens ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { - e.SetServiceAccountName("my-service-account") + e.SetServiceAccountName("worker-sa") return nil }) ``` ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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("app"), func(e *editors.ContainerEditor) error { +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` to target the `ReplicaSet` object itself, or -`m.EditPodTemplateMetadata` to target the pod template. +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`. @@ -165,14 +160,69 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno | `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 -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. +**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 that selectors in -the same mutation resolve correctly and reconciliation remains idempotent. +**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/docs/primitives/role.md b/docs/primitives/role.md index eaf85fc1..25c8b65f 100644 --- a/docs/primitives/role.md +++ b/docs/primitives/role.md @@ -1,16 +1,18 @@ # Role Primitive -The `role` primitive is the framework's built-in static abstraction for managing Kubernetes `Role` resources. It -integrates with the component lifecycle and provides a structured mutation API for managing RBAC policy rules and object -metadata. +The `role` primitive wraps a Kubernetes `Role` and manages RBAC policy rules and object metadata within the component +lifecycle. ## Capabilities -| Capability | Detail | -| --------------------- | --------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | -| **Mutation pipeline** | Typed editors for `.rules` and object metadata, with a raw escape hatch for free-form access | -| **Data extraction** | Reads generated or updated values back from the reconciled Role after each sync cycle | +| 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 @@ -32,42 +34,18 @@ base := &rbacv1.Role{ } resource, err := role.NewBuilder(base). - WithMutation(MyFeatureMutation(owner.Spec.Version)). + WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableSecretAccess)). Build() ``` -## Mutations - -Mutations are the primary mechanism for modifying a `Role` beyond its baseline. Each mutation is a named function that -receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +`Build()` returns an error if `Name` or `Namespace` is empty. -```go -func MyFeatureMutation(version string) role.Mutation { - return role.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *role.Mutator) error { - m.EditRules(func(e *editors.PolicyRulesEditor) error { - e.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"configmaps"}, - Verbs: []string{"get"}, - }) - return nil - }) - return nil - }, - } -} -``` +Identity format: `rbac.authorization.k8s.io/v1/Role//`. -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. +## Mutations -### Boolean-gated 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 { @@ -89,71 +67,39 @@ func SecretAccessMutation(version string, enabled bool) role.Mutation { } ``` -### Version-gated mutations - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyRoleMutation(version string) role.Mutation { - return role.Mutation{ - Name: "legacy-role", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *role.Mutator) error { - m.EditRules(func(e *editors.PolicyRulesEditor) error { - e.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{"extensions"}, - Resources: []string{"ingresses"}, - Verbs: []string{"get", "list"}, - }) - return nil - }) - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 | +| 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 are applied in their registration order. Later features observe the Role as modified by all -previous features. +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: - -```go -m.EditRules(func(e *editors.PolicyRulesEditor) error { - e.SetRules([]rbacv1.PolicyRule{ - {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, - }) - return nil -}) -``` +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 the mutation should define the complete set of -rules, discarding any previously accumulated entries. +`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(desiredRules) + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}}, + }) return nil }) ``` @@ -182,7 +128,6 @@ methods are sufficient: ```go m.EditRules(func(e *editors.PolicyRulesEditor) error { raw := e.Raw() - // Filter out rules that grant write access filtered := (*raw)[:0] for _, r := range *raw { if !containsVerb(r.Verbs, "create") { @@ -196,9 +141,8 @@ m.EditRules(func(e *editors.PolicyRulesEditor) error { ### ObjectMetaEditor -Modifies labels and annotations via `m.EditObjectMetadata`. - -Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. ```go m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { @@ -208,7 +152,21 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { }) ``` -## Full Example: Feature-Composed Permissions +## 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 { @@ -247,24 +205,24 @@ func SecretAccessMutation(version string, enabled bool) role.Mutation { resource, err := role.NewBuilder(base). WithMutation(BaseRuleMutation(owner.Spec.Version)). - WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)). + WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableSecretAccess)). Build() ``` -When `EnableTracing` is true, the final Role will contain 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. +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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**`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 need to contribute rules to the same Role, -`AddRule` lets each feature add its permissions independently. Using `SetRules` in multiple features means the last -registration wins. Only use that when full replacement is the intended semantics. +**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. -**Register mutations in dependency order.** If mutation B relies on rules set by mutation A, register A first. +**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. -**PolicyRule has no unique key.** There is no upsert or remove-by-key operation. 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/docs/primitives/rolebinding.md b/docs/primitives/rolebinding.md index 4ccac00c..39b63d43 100644 --- a/docs/primitives/rolebinding.md +++ b/docs/primitives/rolebinding.md @@ -1,17 +1,19 @@ # RoleBinding Primitive -The `rolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes `RoleBinding` -resources. It integrates with the component lifecycle and provides a structured mutation API for managing subjects and -object metadata. +The `rolebinding` primitive wraps a Kubernetes `RoleBinding` and manages the subjects list and object metadata within +the component lifecycle. ## Capabilities -| Capability | Detail | -| --------------------- | -------------------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | -| **Mutation pipeline** | Typed editors for subjects and object metadata, with a raw escape hatch for free-form access | -| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation (requires delete/recreate) | -| **Data extraction** | Reads generated or updated values back from the reconciled RoleBinding after each sync cycle | +| 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 @@ -34,42 +36,22 @@ base := &rbacv1.RoleBinding{ } resource, err := rolebinding.NewBuilder(base). - WithMutation(MySubjectMutation(owner.Spec.Version)). + WithMutation(MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)). Build() ``` -`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. +`Build()` returns an error if `Name` or `Namespace` is empty, or if `roleRef.APIGroup`, `roleRef.Kind`, or +`roleRef.Name` is empty. -## Mutations - -Mutations are the primary mechanism for modifying a `RoleBinding` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. +`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. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +Identity format: `rbac.authorization.k8s.io/v1/RoleBinding//`. -```go -func AddServiceAccountMutation(version, saName, saNamespace string) rolebinding.Mutation { - return rolebinding.Mutation{ - Name: "add-service-account", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *rolebinding.Mutator) error { - m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureSubject(rbacv1.Subject{ - Kind: "ServiceAccount", - Name: saName, - Namespace: saNamespace, - }) - return nil - }) - return nil - }, - } -} -``` +## Mutations -### Boolean-gated 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 { @@ -78,37 +60,7 @@ func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutatio Feature: feature.NewVersionGate(version, nil).When(enabled), Mutate: func(m *rolebinding.Mutator) error { m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureSubject(rbacv1.Subject{ - Kind: "ServiceAccount", - Name: "monitoring-agent", - Namespace: "monitoring", - }) - return nil - }) - return nil - }, - } -} -``` - -### Version-gated mutations - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacySubjectMutation(version string) rolebinding.Mutation { - return rolebinding.Mutation{ - Name: "legacy-subject", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *rolebinding.Mutator) error { - m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureSubject(rbacv1.Subject{ - Kind: "User", - Name: "legacy-admin", - }) + e.EnsureServiceAccount("monitoring-agent", "monitoring") return nil }) return nil @@ -117,26 +69,28 @@ func LegacySubjectMutation(version string) rolebinding.Mutation { } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 | +| 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 are applied in their registration order. Later features observe the RoleBinding as modified -by all previous features. +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: +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 { @@ -153,16 +107,29 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { #### 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. +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 +#### RemoveSubject and RemoveServiceAccount -`RemoveSubject` removes a subject identified by kind, name, and namespace. It is a no-op if no matching subject exists. +`RemoveSubject` removes a subject identified by kind, name, and namespace. `RemoveServiceAccount` is a convenience +wrapper for removing `ServiceAccount` subjects: -#### Raw +```go +e.RemoveSubject("User", "old-user", "") +e.RemoveServiceAccount("deprecated-sa", "default") +``` -`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` slice for free-form access when the structured methods -are insufficient: +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` for free-form editing: ```go m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { @@ -177,9 +144,8 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { ### ObjectMetaEditor -Modifies labels and annotations via `m.EditObjectMetadata`. - -Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. +Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`, +`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. ```go m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { @@ -189,16 +155,69 @@ 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: + +```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. -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. - **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/docs/primitives/secret.md b/docs/primitives/secret.md index 8cf46dca..7c7aee84 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -1,16 +1,18 @@ # Secret Primitive -The `secret` primitive is the framework's built-in static abstraction for managing Kubernetes `Secret` resources. It -integrates with the component lifecycle and provides a structured mutation API for managing `.data` and `.stringData` -entries and object metadata. +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 | -| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | +| 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 @@ -34,17 +36,18 @@ resource, err := secret.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that -receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +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), // always enabled + Feature: feature.NewVersionGate(version, nil), Mutate: func(m *secret.Mutator) error { m.SetData("feature-flag", []byte("enabled")) return nil @@ -53,62 +56,22 @@ func MyFeatureMutation(version string) secret.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -```go -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 - }, - } -} -``` - -### Version-gated mutations - -```go -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 - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 are applied in their registration order. Later edits in the same mutation observe the Secret -as modified by all earlier edits. +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: @@ -151,8 +114,7 @@ m.EditData(func(e *editors.SecretDataEditor) error { #### 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 when none of the structured methods -are sufficient: +`map[string]string` for `.stringData`. Both give direct access for free-form editing: ```go m.EditData(func(e *editors.SecretDataEditor) error { @@ -196,9 +158,9 @@ single edit block. ## Data Hash -Two utilities are provided for computing 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. +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 @@ -208,11 +170,10 @@ with this hash so that a secret change triggers a rolling restart. hash, err := secret.DataHash(s) ``` -The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically, so it is -deterministic regardless of insertion order. Both `.data` and `.stringData` are included: `.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 (which may use `.stringData`) or -a cluster-read object (where `.stringData` has already been merged into `.data`). +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 @@ -228,12 +189,12 @@ secretResource, err := secret.NewBuilder(base). hash, err := secretResource.DesiredHash() ``` -The hash covers only operator-controlled fields. Only changes to operator-owned content will change the hash. +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 +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 @@ -259,7 +220,7 @@ if err != nil { } comp, err := component.NewComponentBuilder(). - WithResource(secretResource). // reconciled first + WithResource(secretResource). // reconciled first WithResource(deployResource). Build() ``` @@ -281,16 +242,70 @@ func ChecksumAnnotationMutation(version, secretHash string) deployment.Mutation } ``` -When the secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same +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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**`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/docs/primitives/service.md b/docs/primitives/service.md index 2a201159..609efd26 100644 --- a/docs/primitives/service.md +++ b/docs/primitives/service.md @@ -1,18 +1,20 @@ # Service Primitive -The `service` primitive is the framework's built-in integration abstraction for managing Kubernetes `Service` resources. -It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a structured -mutation API for managing ports, selectors, and service configuration. +The `service` primitive wraps a Kubernetes `Service` and integrates with the component lifecycle as an Integration, +Graceful, and Suspendable resource. ## Capabilities -| Capability | Detail | -| ------------------------ | --------------------------------------------------------------------------------------------- | -| **Operational tracking** | Monitors LoadBalancer ingress assignment; reports `Operational` or `Pending` | -| **Suspension** | Unaffected by suspension by default; customizable via handlers to delete or mutate on suspend | -| **Grace status** | LoadBalancer with no ingress reports `Degraded`; non-LoadBalancer or has ingress is `Healthy` | -| **Mutation pipeline** | Typed editors for metadata and service spec, with a raw escape hatch for free-form access | -| **Data extraction** | Reads generated or updated values (ClusterIP, LoadBalancer ingress) after each sync cycle | +| 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 @@ -21,7 +23,7 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/servic base := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Name: "app-service", + Name: "app-svc", Namespace: owner.Namespace, }, Spec: corev1.ServiceSpec{ @@ -33,37 +35,18 @@ base := &corev1.Service{ } resource, err := service.NewBuilder(base). - WithMutation(MyFeatureMutation(owner.Spec.Version)). + WithMutation(BaseServiceMutation(owner.Spec.Version)). Build() ``` ## Mutations -Mutations are the primary mechanism for modifying a `Service` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. +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). -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: - -```go -func MyFeatureMutation(version string) service.Mutation { - return service.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *service.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: +A kind-specific example, gating a NodePort mutation on a boolean condition: ```go func NodePortMutation(version string, enabled bool) service.Mutation { @@ -81,51 +64,25 @@ func NodePortMutation(version string, enabled bool) service.Mutation { } ``` -### Version-gated mutations - -Pass a `[]feature.VersionConstraint` to gate on a semver range: - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyPortMutation(version string) service.Mutation { - return service.Mutation{ - Name: "legacy-port", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), - Mutate: func(m *service.Mutator) error { - m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { - e.EnsurePort(corev1.ServicePort{Name: "legacy", Port: 9090}) - return nil - }) - return nil - }, - } -} -``` - -All version constraints and `When()` conditions must be satisfied for a mutation to apply. - ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded: +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 edits | Ports, selectors, type, traffic policies | +| 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 are applied in their registration order. Later features observe the Service as modified by -all previous features. +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`. +Controls Service-level settings via `m.EditServiceSpec`. Available methods: `SetType`, `EnsurePort`, `RemovePort`, `SetSelector`, `EnsureSelector`, `RemoveSelector`, `SetSessionAffinity`, `SetSessionAffinityConfig`, `SetPublishNotReadyAddresses`, `SetExternalTrafficPolicy`, @@ -146,10 +103,10 @@ m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { #### Port Management -`EnsurePort` upserts a port: if a port with the same `Name` exists, it is replaced; otherwise, when `Name` is empty, the -match is performed on the combination of `Port` and the effective `Protocol` (treating an empty protocol value as TCP). -This means TCP and UDP ports with the same port number are considered distinct unless you explicitly set matching -protocols. If no existing port matches, the new port is appended. `RemovePort` removes a port by name. +`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 { @@ -166,13 +123,13 @@ m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { ```go m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { - e.EnsureSelector("app", "myapp") - e.EnsureSelector("env", "production") + e.EnsureSelector("app", "web") + e.EnsureSelector("tier", "frontend") return nil }) ``` -For fields not covered by the typed API, use `Raw()`: +Use `Raw()` for fields not covered by the typed API: ```go m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { @@ -195,26 +152,40 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { }) ``` -## Operational Status +## Data Extraction + +Use `WithDataExtractor` to read values from the reconciled Service after each sync cycle, such as the assigned ClusterIP +or LoadBalancer ingress: -The Service primitive implements the `Operational` concept to track whether the Service is ready to accept traffic. +```go +var assignedIP string -### DefaultOperationalStatusHandler +resource, err := service.NewBuilder(base). + WithDataExtractor(func(svc corev1.Service) error { + assignedIP = svc.Spec.ClusterIP + return nil + }). + Build() +``` -| Service Type | Behaviour | -| -------------- | ------------------------------------------------------------------------------------------------------------ | -| `LoadBalancer` | Reports `Pending` until `Status.LoadBalancer.Ingress` has entries with an IP or hostname; then `Operational` | -| `ClusterIP` | Immediately `Operational` | -| `NodePort` | Immediately `Operational` | -| `ExternalName` | Immediately `Operational` | -| Headless | Immediately `Operational` | +## Operational Status + +The Service primitive implements `concepts.Operational`. The default handler reports: -Override with `WithCustomOperationalStatus` to add custom checks: +| 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) { - // Custom logic, e.g. check for specific annotations return service.DefaultOperationalStatusHandler(op, svc) }). Build() @@ -222,8 +193,7 @@ resource, err := service.NewBuilder(base). ## Grace Status -The default grace status handler inspects the Service type and load balancer status to assess health after the grace -period expires: +The default grace status handler assesses health after the grace period expires: | Service Type | Condition | Status | | -------------- | ----------------------------------------- | ---------- | @@ -250,42 +220,26 @@ service.NewBuilder(base). ## Suspension -By default, Services are **unaffected** by suspension. They remain in the cluster when the parent component is -suspended. The default suspend mutation handler is a no-op, `DefaultDeleteOnSuspendHandler` returns `false`, and the -default suspension status handler reports `Suspended` immediately (no work required). +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` if you want to delete the Service on suspend: +Override with `WithCustomSuspendDeletionDecision` to delete the Service on suspend: ```go resource, err := service.NewBuilder(base). WithCustomSuspendDeletionDecision(func(_ *corev1.Service) bool { - return true // delete the Service during suspension + return true }). Build() ``` -You can also combine `WithCustomSuspendMutation` and `WithCustomSuspendStatus` for more advanced suspension behaviour, -such as modifying the Service before it is deleted or tracking external readiness before reporting suspended. - -## Data Extraction - -Use `WithDataExtractor` to read values from the reconciled Service, 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() -``` +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: Feature-Composed Service +## Full Example ```go func BaseServiceMutation(version string) service.Mutation { @@ -324,22 +278,32 @@ func MetricsPortMutation(version string, enabled bool) service.Mutation { } } +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 will expose both the HTTP port and the metrics port. When false, only the HTTP -port is configured. Neither mutation needs to know about the other. +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` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean -conditions. +**`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 relies on a port added by mutation A, register A first. +**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.** The mutator tracks ports by name (or port number when unnamed), so +**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/docs/primitives/serviceaccount.md b/docs/primitives/serviceaccount.md index e8834f59..5fd849c8 100644 --- a/docs/primitives/serviceaccount.md +++ b/docs/primitives/serviceaccount.md @@ -1,16 +1,18 @@ # ServiceAccount Primitive -The `serviceaccount` primitive is the framework's built-in static abstraction for managing Kubernetes `ServiceAccount` -resources. It integrates with the component lifecycle and provides a structured mutation API for managing image pull -secrets, the automount token flag, and object metadata. +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 | Detail | -| --------------------- | --------------------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state | -| **Mutation pipeline** | Direct mutator methods for `.imagePullSecrets` and `.automountServiceAccountToken`, plus metadata editors | -| **Data extraction** | Reads generated or updated values back from the reconciled ServiceAccount after each sync cycle | +| 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 @@ -25,93 +27,56 @@ base := &corev1.ServiceAccount{ } resource, err := serviceaccount.NewBuilder(base). - WithMutation(MyFeatureMutation(owner.Spec.Version)). + WithMutation(BaseTokenMutation(owner.Spec.Version)). Build() ``` -## Mutations - -Mutations are the primary mechanism for modifying a `ServiceAccount` beyond its baseline. Each mutation is a named -function that receives a `*Mutator` and records edit intent through direct methods. +`Build()` returns an error if `Name` or `Namespace` is empty. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +Identity format: `v1/ServiceAccount//`. -```go -func MyFeatureMutation(version string) serviceaccount.Mutation { - return serviceaccount.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *serviceaccount.Mutator) error { - m.EnsureImagePullSecret("my-registry") - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -```go -func PrivateRegistryMutation(version string, usePrivateRegistry bool) serviceaccount.Mutation { - return serviceaccount.Mutation{ - Name: "private-registry", - Feature: feature.NewVersionGate(version, nil).When(usePrivateRegistry), - Mutate: func(m *serviceaccount.Mutator) error { - m.EnsureImagePullSecret("private-registry-creds") - return nil - }, - } -} -``` +## Mutations -### Version-gated 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 -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyTokenMutation(version string) serviceaccount.Mutation { +func BaseTokenMutation(version string) serviceaccount.Mutation { return serviceaccount.Mutation{ - Name: "legacy-token", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), + Name: "base-token", + Feature: feature.NewVersionGate(version, nil), Mutate: func(m *serviceaccount.Mutator) error { - v := true - m.SetAutomountServiceAccountToken(&v) + m.EnsureImagePullSecret("default-registry") return nil }, } } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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, edit operations are applied in a fixed category order regardless of the order they are -recorded: +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 | +| 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 are applied in their registration order. Later features observe the ServiceAccount as -modified by all previous features. +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`. +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 { @@ -123,18 +88,21 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## 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("my-registry-creds") +m.EnsureImagePullSecret("registry-creds") ``` ### RemoveImagePullSecret -Removes a named image pull secret from `.imagePullSecrets`. It is a no-op if the secret is not present. +Removes a named image pull secret from `.imagePullSecrets`. No-op if the name is not present. ```go m.RemoveImagePullSecret("old-registry-creds") @@ -142,19 +110,35 @@ m.RemoveImagePullSecret("old-registry-creds") ### SetAutomountServiceAccountToken -Sets `.automountServiceAccountToken` to the provided value. Pass `nil` to unset the field. +Sets `.automountServiceAccountToken`. Pass `nil` to unset the field. ```go v := false m.SetAutomountServiceAccountToken(&v) ``` -## Full Example: Feature-Composed ServiceAccount +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 -func BaseImagePullSecretMutation(version string) serviceaccount.Mutation { +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: "base-pull-secret", + Name: "pull-secret", Feature: feature.NewVersionGate(version, nil), Mutate: func(m *serviceaccount.Mutator) error { m.EnsureImagePullSecret("default-registry") @@ -163,10 +147,10 @@ func BaseImagePullSecretMutation(version string) serviceaccount.Mutation { } } -func DisableAutomountMutation(version string, disableAutomount bool) serviceaccount.Mutation { +func DisableAutomountMutation(version string, disable bool) serviceaccount.Mutation { return serviceaccount.Mutation{ Name: "disable-automount", - Feature: feature.NewVersionGate(version, nil).When(disableAutomount), + Feature: feature.NewVersionGate(version, nil).When(disable), Mutate: func(m *serviceaccount.Mutator) error { v := false m.SetAutomountServiceAccountToken(&v) @@ -176,21 +160,26 @@ func DisableAutomountMutation(version string, disableAutomount bool) serviceacco } resource, err := serviceaccount.NewBuilder(base). - WithMutation(BaseImagePullSecretMutation(owner.Spec.Version)). + 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 is left at its baseline value. Neither mutation needs to know about the other. +field stays at its baseline value. ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**`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 with each other. +required pull secrets without conflicting. + +**Register mutations in dependency order.** If one mutation depends on a field set by another, register the dependency +first. -**Register mutations in dependency order.** If mutation B relies on a secret added by mutation A, register A 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/docs/primitives/statefulset.md b/docs/primitives/statefulset.md index fa6fa7b9..bfa3ae0b 100644 --- a/docs/primitives/statefulset.md +++ b/docs/primitives/statefulset.md @@ -1,17 +1,18 @@ # StatefulSet Primitive -The `statefulset` primitive is the framework's built-in workload abstraction for managing Kubernetes `StatefulSet` -resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, -pod specs, metadata, and volume claim templates. +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 -| Capability | Detail | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling`; grace handler can mark Down/Degraded | -| **Rollout health** | Surfaces stalled or failing rollouts by transitioning the resource to `Degraded` or `Down` (no grace-period timing) | -| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, statefulset spec, pod spec, containers, and volume claim templates | +| [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 @@ -48,65 +49,17 @@ resource, err := statefulset.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `StatefulSet` beyond its baseline. Each mutation is a named function -that receives a `*Mutator` and records edit intent through typed editors. - -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature -with no version constraints and no `When()` conditions is also always enabled: +Each mutation is a named `statefulset.Mutation` that receives a `*statefulset.Mutator` and records edits through typed +editors. ```go -func MyFeatureMutation(version string) statefulset.Mutation { +func StorageMutation(version string) statefulset.Mutation { return statefulset.Mutation{ - Name: "my-feature", - Feature: feature.NewVersionGate(version, nil), // always enabled - Mutate: func(m *statefulset.Mutator) error { - // record edits here - return nil - }, - } -} -``` - -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by -another, register the dependency first. - -### Boolean-gated mutations - -Use `When(bool)` to gate a mutation on a runtime condition: - -```go -func TracingMutation(version string, enabled bool) statefulset.Mutation { - return statefulset.Mutation{ - Name: "tracing", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *statefulset.Mutator) error { - m.EnsureInitContainer(corev1.Container{ - Name: "init-config", - Image: "config-init:latest", - }) - return nil - }, - } -} -``` - -### Version-gated mutations - -Pass a `[]feature.VersionConstraint` to gate on a semver range: - -```go -var legacyConstraint = mustSemverConstraint("< 2.0.0") - -func LegacyStorageMutation(version string) statefulset.Mutation { - return statefulset.Mutation{ - Name: "legacy-storage", - Feature: feature.NewVersionGate( - version, - []feature.VersionConstraint{legacyConstraint}, - ), + 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: "STORAGE_BACKEND", Value: "legacy"}) + e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"}) return nil }) return nil @@ -115,16 +68,17 @@ func LegacyStorageMutation(version string) statefulset.Mutation { } ``` -All version constraints and `When()` conditions must be satisfied for a mutation to apply. +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 a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the -order they are recorded. This ensures structural consistency across mutations. +Within each feature, edits run in this fixed category order: | Step | Category | What it affects | | ---- | -------------------------------- | ----------------------------------------------------------------------- | -| 1 | StatefulSet metadata edits | Labels and annotations on the `StatefulSet` object | +| 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 | @@ -134,11 +88,13 @@ order they are recorded. This ensures structural consistency across mutations. | 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 mutation. -This means a single mutation can add a container and then configure it without selector resolution issues. +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`. @@ -155,7 +111,7 @@ m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { }) ``` -For fields not covered by the typed API, use `Raw()`: +Use `Raw()` for fields the typed API does not cover: ```go m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { @@ -191,8 +147,8 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a -[selector](../primitives.md#container-selectors). +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`. @@ -207,28 +163,47 @@ m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `StatefulSet` object itself, or -`m.EditPodTemplateMetadata` to target the pod template. +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 -// On the StatefulSet itself m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app.kubernetes.io/version", version) return nil }) - -// On the pod template 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 -The mutator provides `EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` for managing persistent storage: +`EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` manage persistent storage templates: ```go m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ @@ -244,22 +219,24 @@ m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ }) ``` -**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. These mutation methods are -primarily useful for constructing the initial desired state or when recreating a StatefulSet. +!!! warning "VolumeClaimTemplates are immutable after creation" -## Convenience Methods + `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. -The `Mutator` also exposes convenience wrappers: +## Suspension -| 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)` | +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`. -## Full Example: Database StatefulSet with Storage +Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision` +on the builder. + +## Full Example ```go func DatabaseMutation(version string) statefulset.Mutation { @@ -267,14 +244,12 @@ func DatabaseMutation(version string) statefulset.Mutation { Name: "database-storage", Feature: feature.NewVersionGate(version, nil), Mutate: func(m *statefulset.Mutator) error { - // Configure the StatefulSet spec m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { e.SetReplicas(3) e.SetPodManagementPolicy(appsv1.OrderedReadyPodManagement) return nil }) - // Add a volume claim template for persistent data m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: "data"}, Spec: corev1.PersistentVolumeClaimSpec{ @@ -287,7 +262,6 @@ func DatabaseMutation(version string) statefulset.Mutation { }, }) - // Mount the volume in the database container m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ Name: "data", @@ -304,17 +278,19 @@ func DatabaseMutation(version string) statefulset.Mutation { ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use -`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean +**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. -The internal ordering within each mutation handles intra-mutation dependencies automatically. - -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in -the same mutation resolve correctly and reconciliation remains idempotent. +Internal ordering within each mutation handles intra-mutation dependencies automatically. -**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. +**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. +**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. Changing the templates +requires recreating the StatefulSet. diff --git a/docs/primitives/unstructured.md b/docs/primitives/unstructured.md index 0725139a..b76c5e71 100644 --- a/docs/primitives/unstructured.md +++ b/docs/primitives/unstructured.md @@ -1,39 +1,48 @@ # Unstructured Primitives -The `unstructured` primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type -definition available at compile time: Crossplane resources, external CRDs, or any object known only at runtime. +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. -Four variants are provided, one per [lifecycle category](../primitives.md#primitive-categories): +## When to Use Unstructured -| Package | Category | Lifecycle Interfaces | -| ----------------------------------------- | ----------- | ------------------------------------------------------------------------ | -| `pkg/primitives/unstructured/static` | Static | `Guardable`, `DataExtractable` | -| `pkg/primitives/unstructured/workload` | Workload | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | -| `pkg/primitives/unstructured/integration` | Integration | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | -| `pkg/primitives/unstructured/task` | Task | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | +Choose between the three approaches in this order: -## No Semantic Defaults +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` | -Because the framework cannot know the semantics of an unstructured object, **no domain-specific status or suspension -behavior is inferred**. The unstructured builders only configure generic safe defaults: grace status defaults to -`Healthy`, suspension status to `Suspended`, and suspension mutations are no-ops. Only the converging or operational -status handler is required at build time; all other handlers are optional and fall back to these safe defaults when -omitted. Calling `Build()` without the required handler returns an error. +## No Semantic Defaults -### Required Handlers per Variant +Because the framework has no type information for unstructured objects, it infers no domain-specific status or +suspension behavior. Safe fallbacks are configured instead: -| Variant | Required at `Build()` | Optional | -| --------------- | --------------------- | -------------------------------------------------------- | -| **Static** | _(none)_ | All optional | -| **Workload** | `ConvergingStatus` | `GraceStatus` (defaults to Healthy), suspension handlers | -| **Integration** | `OperationalStatus` | `GraceStatus` (defaults to Healthy), suspension handlers | -| **Task** | `ConvergingStatus` | Suspension handlers | +- Grace status defaults to `Healthy` when no handler is provided. +- Suspension status defaults to `Suspended` immediately (no-op suspend mutation, `DeleteOnSuspend` returns `false`). -Suspension handlers default to safe no-ops when omitted: `DeleteOnSuspend()` returns false, `Suspend()` is a no-op, and -`SuspensionStatus()` reports `Suspended` immediately. Override these via `WithCustomSuspendDeletionDecision`, -`WithCustomSuspendMutation`, and `WithCustomSuspendStatus` if the resource needs custom suspension behavior. +Only the converge or operational status handler is required. All other handlers are optional. Calling `Build()` without +the required handler returns an error. -## Building an Unstructured Primitive +## Building Unstructured Primitives ### Static (simplest) @@ -46,13 +55,13 @@ import ( obj := &uns.Unstructured{} obj.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "example.crossplane.io", Version: "v1alpha1", Kind: "Database", + Group: "example.io", Version: "v1alpha1", Kind: "Widget", }) -obj.SetName("my-database") +obj.SetName("my-widget") obj.SetNamespace(owner.Namespace) resource, err := static.NewBuilder(obj). - WithMutation(myMutation(owner.Spec.Version)). + WithMutation(RegionMutation(owner.Spec.Version, owner.Spec.Region)). Build() ``` @@ -68,7 +77,6 @@ import ( resource, err := workload.NewBuilder(obj). WithCustomConvergeStatus(func(op concepts.ConvergingOperation, o *uns.Unstructured) (concepts.AliveStatusWithReason, error) { - // Inspect o.Object to determine health ready, _, _ := uns.NestedBool(o.Object, "status", "ready") if ready { return concepts.AliveStatusWithReason{ @@ -81,25 +89,38 @@ resource, err := workload.NewBuilder(obj). Reason: "waiting for readiness", }, nil }). - WithCustomGraceStatus(func(o *uns.Unstructured) (concepts.GraceStatusWithReason, error) { - return concepts.GraceStatusWithReason{Status: concepts.GraceStatusDegraded}, nil - }). - WithCustomSuspendStatus(func(o *uns.Unstructured) (concepts.SuspensionStatusWithReason, error) { - return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil - }). - WithCustomSuspendMutation(func(m *unstruct.Mutator) error { - return nil - }). - WithCustomSuspendDeletionDecision(func(o *uns.Unstructured) bool { - return true // delete on suspend + 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 validates that the object's namespace is empty -and formats the identity string without a namespace segment. +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). @@ -109,8 +130,11 @@ resource, err := static.NewBuilder(obj). ## Mutations -Mutations follow the same pattern as typed primitives. The `Mutation` type is defined in the shared -`primitives/unstructured` package and used by all four variants: +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 ( @@ -139,36 +163,40 @@ func RegionMutation(version, region string) unstruct.Mutation { ## Internal Mutation Ordering -Within each feature, mutations execute in the following order: +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. -1. **Metadata edits**: labels and annotations via `ObjectMetaEditor` -2. **Content edits**: nested fields via `UnstructuredContentEditor` +## Relevant Editors -Features are applied in registration order. Later features observe the object as modified by all previous features. +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 +### UnstructuredContentEditor The `UnstructuredContentEditor` wraps the object's `map[string]interface{}` content and provides structured operations -for setting and removing values at nested paths. - -### Methods - -| 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/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 | - -### Raw Escape Hatch +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: @@ -185,37 +213,129 @@ m.EditContent(func(e *editors.UnstructuredContentEditor) error { }) ``` +### 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: +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: `{group}/{version}/{kind}/{namespace}/{name}` +- Cluster-scoped: `{group}/{version}/{kind}/{name}` -Namespaced resources must have a non-empty namespace set on the object; `Build()` rejects empty namespaces. +Namespaced resources must have a non-empty namespace; `Build()` rejects empty namespaces unless `MarkClusterScoped()` +was called. ## Data Extraction -All four variants support data extraction. Extractors receive a value copy of the reconciled object: +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.DatabaseIP = ip + 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 matching the object's runtime behavior. If the object runs continuously - and has observable health, use `workload`. If it depends on external assignments, use `integration`. If it runs to - completion, use `task`. If it is configuration-like, use `static`. -- **Handlers encode your domain knowledge.** Since the framework has no type information for unstructured objects, the - handlers you provide are the only source of lifecycle semantics. Inspect `obj.Object` fields to determine status. -- **Use typed primitives when possible.** Unstructured primitives trade compile-time safety for runtime flexibility. - Prefer typed primitives for standard Kubernetes resources. -- **Test your handlers.** Without domain-specific defaults as a safety net, handler correctness is entirely on the - operator author. Write table-driven tests covering all status transitions. +**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 cd9c3e8a6ae57f030f0fdc518a18e7e79d5951a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:28:28 +0200 Subject: [PATCH 07/37] docs: rewrite guidelines, testing, and compatibility guides Guidelines: restructure into a practical guideline set grounded in real production usage patterns (desired-state baselines, pure mutations, the defaults/compat/overrides layering, fail-loud version floors, user-override escape hatch, named mutations, continue-on-error across components), with neutral examples. Testing: lead with golden.WithScheme (effectively mandatory for typed objects), add a which-tool-when guide, document the Previewer/ComponentPreviewer contracts, and show the explicit go test ./path -update form. Compatibility: add the Go minimum, clarify what Tested status means for production, add a deprecation note and a workflow status badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/compatibility.md | 26 +- docs/guidelines.md | 924 +++++++++++++++++++----------------------- docs/testing.md | 203 +++++++--- 3 files changed, 563 insertions(+), 590 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 15a92839..da38cf27 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,5 +1,13 @@ # 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: @@ -9,8 +17,13 @@ The framework is tested against the following version combinations: | 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** versions are verified -weekly by the compatibility CI workflow. +**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 @@ -18,9 +31,12 @@ The framework targets the latest stable controller-runtime release as its primar 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. -Versions v0.21 and below are incompatible due to multiple transitive dependency module path migrations in the Kubernetes -ecosystem (`structured-merge-diff` v4 to v6, `yaml` package path changes) that cannot be resolved by swapping direct -dependencies alone. +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 diff --git a/docs/guidelines.md b/docs/guidelines.md index 86362db8..a8fd48d8 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -1,176 +1,85 @@ # Guidelines -Recommendations for structuring operators built with the framework. These are not hard rules. They reflect patterns that -are effective and pitfalls that are easy to walk into. +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 core object passed to a primitive builder should represent the latest desired state of the resource. When the -baseline evolves, mutations adapt to the new baseline, not the other way around. The baseline should never be held back -at a legacy shape to accommodate existing mutations. Mutations layer cross-cutting concerns and conditional features on -top of whatever the current baseline is. +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 -dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-web", - Namespace: owner.Namespace, - Labels: map[string]string{"app": owner.Name}, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: ptr.To(owner.Spec.Replicas), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": owner.Name}, +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"}, }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": owner.Name}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(app.Spec.Backend.Replicas), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": app.Name, "component": "backend"}, }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: fmt.Sprintf("my-app:%s", owner.Spec.Version), - }, + 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. + }}, }, }, }, - }, + } } - -res, err := deployment.NewBuilder(dep). - WithMutation(TracingFeature(owner.Spec.TracingEnabled)). - WithMutation(MetricsFeature(owner.Spec.MetricsEnabled)). - Build() ``` -The baseline captures the structural truth of the resource: its name, namespace, labels, selector, replica count, and -primary container image. Mutations handle orthogonal concerns like injecting a tracing sidecar or adding metrics -annotations. Each mutation is independently gated and does not depend on the baseline having been set up in a particular -way. +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. -### Why this is worth the effort +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. -The alternative is to start from a minimal or legacy object and build up the current shape through mutations: +## Mutations Are Pure Functions of the Spec -```go -dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-web", - Namespace: owner.Namespace, - }, -} - -res, err := deployment.NewBuilder(dep). - WithMutation(SetLabels(owner)). - WithMutation(SetReplicas(owner)). - WithMutation(SetSelector(owner)). - WithMutation(SetImage(owner)). - WithMutation(TracingFeature(owner.Spec.TracingEnabled)). - Build() -``` +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 feels simpler at first because every field goes through the same mechanism. But over time it creates problems: - -- **The baseline tells you nothing.** Reading the code requires tracing through every mutation to understand what the - resource actually looks like. A new contributor cannot glance at the object literal and know the shape of the - deployment. -- **Mutation ordering becomes load-bearing for structural fields.** `SetSelector` must run before anything that depends - on the selector existing. `SetImage` must run before a version-aware mutation that patches the image tag. These - ordering constraints are invisible and fragile. Cross-cutting mutations (tracing, metrics) should be - order-independent, but mixing them with structural mutations means everything is implicitly ordered. -- **The baseline becomes frozen at a legacy shape.** When a new version of your operator changes the resource's - structure (adds a port, changes the container name, adopts a new volume layout), you face a choice: update the - baseline and fix the mutations that assumed the old shape, or add another mutation to patch the baseline forward. The - second choice is easier in the moment, but each time you take it the baseline drifts further from reality. Eventually - you have an empty shell with a stack of mutations that must run in the right order to produce a valid object. - -When the baseline represents the latest desired state, these problems go away. The baseline is readable on its own. -Mutations are genuinely independent because they operate on a complete, valid object. When the resource shape changes, -you update the baseline and adjust any mutations that assumed the old shape. The mutations that need adjusting are only -the ones gated on legacy versions, and those mutations are explicitly about backward compatibility rather than silently -load-bearing. - -### Revert mutations vs. forward mutations - -The baseline-as-latest approach means that every structural version change requires a new legacy mutation that reverts -the baseline to the older shape. This is real friction: update the baseline, write a revert mutation, add golden files. -It is natural to wonder whether the opposite direction (baseline stays at the original shape, forward mutations patch it -to the latest version) would be less work. - -In practice, the revert direction is easier to maintain: - -- **Adding a revert mutation does not require changing existing ones.** Each revert mutation handles one version step: - the v2 revert turns v3 back into v2, and the v1 revert turns v2 back into v1. They do execute in order (newest first), - but the v1 revert was written when v2 was the baseline, and it still works because the v2 revert restores the shape it - expects. When you drop support for v1, you delete one mutation and nothing else changes. -- **Forward mutations have fragile ordering dependencies.** A v3 forward patch might assume that the v2 patch already - ran (e.g. it expects a container name or port layout that only exists after v2's mutation). Delete the v2 mutation - when you drop support, and v3 breaks silently because its precondition is gone. -- **You read the baseline more often than you update it.** Structural changes to a resource happen occasionally; reading - the resource definition happens constantly. With baseline-as-latest, a new contributor opens the file and sees the - current shape at a glance. With baseline-as-original, understanding the current shape requires mentally replaying - every forward mutation in order. -- **The two mutation categories have different lifecycles.** Revert mutations are backward compatibility: temporary by - nature, and they shrink as you drop old versions. Feature mutations (tracing, metrics, debug logging) are - cross-cutting concerns with a longer lifecycle. Forward mutations mix both categories in the same pipeline, making it - harder to tell which mutations are temporary compatibility shims and which are permanent features. -- **Where the revert approach costs more.** Each structural version change requires writing a new revert mutation. This - is the tradeoff. But the friction is also a forcing function: it makes the backward-compatibility decision explicit - rather than letting old shapes silently persist as the baseline drifts from reality. - -The number of revert mutations is bounded by the number of supported versions. Most operators support two or three -concurrent versions. When a version falls out of support, its revert mutation is deleted cleanly. Forward mutation -stacks tend to grow indefinitely because removing a forward mutation requires proving that nothing downstream depends on -it. - -### Backward compatibility mutations in practice - -Suppose version 2.0 of your application renamed its container from `"server"` to `"app"` and added a health check port. -The baseline reflects the latest shape: +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. -```go -dep := &appsv1.Deployment{ - // ... - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", // Current name - Image: fmt.Sprintf("my-app:%s", owner.Spec.Version), - Ports: []corev1.ContainerPort{ - {Name: "http", ContainerPort: 8080}, - {Name: "health", ContainerPort: 8081}, // Added in 2.0 - }, - }, - }, - }, - }, - }, -} +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. -res, err := deployment.NewBuilder(dep). - WithMutation(BackwardCompatV1Container(owner.Spec.Version)). - WithMutation(TracingFeature(owner.Spec.TracingEnabled)). - Build() -``` +## Leave Version-Dependent Fields Empty in the Baseline -The backward compat mutation rolls the baseline back for older versions: +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 BackwardCompatV1Container(version string) deployment.Mutation { +func backendImage(app *v1alpha1.WebApp) deployment.Mutation { return deployment.Mutation{ - Name: "BackwardCompatV1Container", - Feature: feature.NewVersionGate(version, []feature.VersionConstraint{ - LessThan("2.0.0"), - }), + Name: "BackendImage", Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { - e.Raw().Name = "server" - e.Raw().Ports = []corev1.ContainerPort{ - {Name: "http", ContainerPort: 8080}, // No health port before 2.0 - } + 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 @@ -179,139 +88,57 @@ func BackwardCompatV1Container(version string) deployment.Mutation { } ``` -Naming the function `BackwardCompat` makes the pattern immediately recognizable. When scanning a builder -chain, `BackwardCompatV1Container` tells you exactly what it does and why it exists without reading the implementation. - -`LessThan` here is a user-provided implementation of `feature.VersionConstraint` that wraps a semver comparison. The -interface requires a single `Enabled(version string) (bool, error)` method, so you can use any semver library to -implement your constraints. - -For version 2.0 and above, the gate is inactive and the baseline is applied as-is. For older versions, the mutation -adjusts the container name and ports back to the legacy shape. The mutation is explicitly about backward compatibility, -gated on the versions that need it, and will stop running entirely once those versions are no longer supported. - -### Verifying backward compatibility mutations - -When you update the baseline, you need confidence that older versions still produce the same object they did before. The -framework provides a `golden` package for this. `AssertYAML` accepts any resource that implements `Preview`, renders it -to YAML, and compares the result against a golden file. - -```go -import "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" - -var update = flag.Bool("update", false, "update golden files") - -func TestDeploymentShape(t *testing.T) { - tests := []struct { - name string - version string - golden string - }{ - {name: "v1.9", version: "1.9.0", golden: "testdata/deployment-v1.9.0.yaml"}, - {name: "v2.0", version: "2.0.0", golden: "testdata/deployment-v2.0.0.yaml"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - owner := &v1alpha1.MyApp{ - Spec: v1alpha1.MyAppSpec{Version: tt.version}, - } - - res, err := buildDeployment(owner) - require.NoError(t, err) - - golden.AssertYAML(t, tt.golden, res, golden.Update(*update)) - }) - } -} -``` - -Each version you care about gets a golden file. When the baseline evolves, run `go test -update` to regenerate the -golden files, then review the diff. The current version's golden file updates to reflect the new shape, but older -version golden files should stay unchanged. If a baseline change accidentally breaks a backward compat mutation, the -snapshot diff shows exactly what shifted. - -A reasonable heuristic for the boundary: if a field is always present regardless of feature flags or version, it belongs -in the baseline. If it is conditional, it belongs in a mutation. - -To snapshot an entire component at once, use `AssertComponentYAML`. It calls `comp.Preview()`, serializes every managed -resource the component would apply into a single multi-document YAML file (documents joined by `---` separators, in -registration order), and compares the result against the golden file. This is useful when you want to verify that a -cross-component change does not accidentally alter any resource's shape. - -```go -func TestComponentShape(t *testing.T) { - owner := &v1alpha1.MyApp{ - Spec: v1alpha1.MyAppSpec{Version: "2.0.0"}, - } - - comp, err := buildWebComponent(owner) - require.NoError(t, err) - - golden.AssertComponentYAML(t, "testdata/web-component.yaml", comp, golden.Update(*update)) -} -``` - -Read-only and delete resources are excluded from the component preview; only resources the component would actively -apply appear in the golden file. +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 CRD's status. If your operator needs to report `DatabaseReady` -and `WebInterfaceReady` independently, those are two components. +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 -dbComp, err := component.NewComponentBuilder(). - WithName("database"). - WithConditionType("DatabaseReady"). - WithResource(statefulSet). - WithResource(dbService). +backendComp, err := component.NewComponentBuilder(). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(backendService). + WithResource(backendStatefulSet). Build() -webComp, err := component.NewComponentBuilder(). - WithName("web-interface"). - WithConditionType("WebInterfaceReady"). - WithResource(deployment). - WithResource(ingress). +frontendComp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(frontendService). + WithResource(frontendDeployment). Build() ``` -Separate components give users and monitoring systems granular observability: "the database is down" is a different -signal from "the web interface is scaling." A problem in one component does not mask the status of another. - -When two components depend on each other (e.g., the web interface needs the database to be ready before it can be -created), use [prerequisites](#use-prerequisites-for-cross-component-dependencies) to express that dependency -declaratively. Guards and data extraction work within a single component's resource list; prerequisites work between -components. - -### When to split vs. combine - -**Split** when: - -- Users would ask "is the database ready?" and "is the web interface ready?" as separate questions. -- Resources can be independently healthy, degraded, or suspended. -- Failure in one group should not mask the status of another. - -**Combine** when: +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. -- Resources only make sense as a unit (a deployment and its service, a job and its configmap). -- Reporting separate conditions would add noise without actionable information. -- Resources share guards or data extraction chains that would be awkward to split across components. +**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. -A deployment and its associated service are a common example of resources worth combining: the service has no useful -"ready" semantics independent of the deployment it fronts. Reporting them as one condition (`WebInterfaceReady`) is -clearer than splitting them into `DeploymentReady` and `ServiceReady`. +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 -Controllers should fetch the owner, decide which components to build, call `Reconcile()`, and defer a single -`component.FlushStatus` to persist status. Business logic, resource construction, and feature decisions belong in -components and their resource builders. +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 *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - owner := &v1alpha1.MyApp{} - if err := r.Get(ctx, req.NamespacedName, owner); err != nil { +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) } @@ -320,206 +147,197 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ Scheme: r.Scheme, Recorder: r.Recorder, Metrics: r.Metrics, - Owner: owner, + 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 } }() - comp, err := buildWebComponent(owner) - if err != nil { - return reconcile.Result{}, err + comps, buildErr := buildComponents(app) + if buildErr != nil { + return reconcile.Result{}, buildErr } - return reconcile.Result{}, comp.Reconcile(ctx, recCtx) + var firstErr error + for _, comp := range comps { + if rErr := comp.Reconcile(ctx, recCtx); rErr != nil && firstErr == nil { + firstErr = rErr + } + } + return reconcile.Result{}, firstErr } ``` -This keeps controller logic trivial to test (there is almost nothing to test) and makes component construction functions -independently testable as pure functions: owner in, component out, no cluster required. +`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. -### Flushing status is the controller's job +If you do not want condition metrics, leave `ReconcileContext.Metrics` as `nil`; `FlushStatus` tolerates a nil recorder +and skips metric emission. -`Component.Reconcile` only mutates the owner's conditions in memory. Persisting them is explicitly the controller's -responsibility, via one `component.FlushStatus` call per reconcile, typically deferred so that conditions set by error -paths (for example, `fail()` in the framework) are still written when `Reconcile` returns an error. +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. -Do not call `FlushStatus` in between component reconciles. With several components per controller the point of the split -is to stage all their conditions in memory first and write them once at the end. Flushing between components brings back -the exact 409 conflict pattern the split was introduced to eliminate. +## Reconciler Error Handling and Requeueing -If you do not want to emit condition metrics, leave `ReconcileContext.Metrics` as `nil`. `FlushStatus` tolerates a nil -recorder and simply skips metric emission. +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 are reconciled in the exact order they are registered with `WithResource()`. This is deliberate: guards and -data extractors depend on it. +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. -If resource B needs data extracted from resource A, register A first: +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("cloud-resources"). - WithConditionType("CloudReady"). - WithResource(roleRes). // Applied first, ARN extracted - WithResource(bucketRes). // Guard checks ARN, applied second + 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() ``` -Reading the `WithResource()` calls top to bottom tells you the execution order. There is no implicit dependency graph to -reconstruct. The flip side is that reordering these calls can silently break data flow between guards and extractors. -Document the dependency when it exists. - -## Mutation Ordering and Container Name Dependencies - -Mutations within a resource are also applied in registration order. Each mutation gets its own feature scope, and later -mutations see the resource as modified by all earlier mutations. This is normally invisible because most mutations are -independent. It becomes visible when a backward compat mutation renames a container and a feature mutation needs to -target that container by name. +The flip side is that reordering these calls can silently break data flow between extractors and guards, so document the +dependency where one exists. -Consider a deployment where the baseline container is named `"app"` (v2+), and a backward compat mutation renames it to -`"server"` for versions before 2.0. A new mutation that sets `LOG_LEVEL=debug` on the application container faces a -question: does it target `"app"` or `"server"`? +## Mutation Ordering and Container-Name Dependencies -The answer depends on registration order, and there are two rules that eliminate the problem. +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. -### Use broad selectors for version-independent mutations +Two rules eliminate the problem: -If a mutation applies to all versions regardless of container name, use `AllContainers()`, `EnsureContainerEnvVar`, or -`EnsureContainerArg`. These selectors never reference a name, so they work whether or not a backward compat rename has -fired. No ordering constraint is needed. +- **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 -// TracingSidecar uses EnsureContainerEnvVar (wraps AllContainers) and is order-insensitive. -func TracingSidecarMutation(enabled bool) deployment.Mutation { - return deployment.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *deployment.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", - }) - return nil - }, - } -} +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() ``` -### Register name-specific mutations before backward compat renames +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. -When a mutation must target a specific container by name, register it before the backward compat mutation that renames -it. Registered in that position, the mutation sees the baseline name because the rename has not fired yet. Its edits -carry through the rename because the backward compat mutation only overwrites specific fields (`Name`, `Ports`), not the -entire container. +## Layer Mutations in a Fixed Order -```go -// DebugLogging targets ContainerNamed("app"), so it must come before BackwardCompatV1Container. -func DebugLoggingMutation(enabled bool) deployment.Mutation { - return deployment.Mutation{ - Name: "DebugLogging", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { - ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) - return nil - }) - return nil - }, - } -} -``` +Order a resource's mutations into fixed layers so the pipeline reads the same way for every workload: -The registration order makes the constraint explicit: +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). -```go -res, err := deployment.NewBuilder(BaseDeployment(owner)). - WithMutation(DebugLoggingMutation(owner.Spec.EnableDebugLogging)). // targets "app" by name - WithMutation(BackwardCompatV1Container(owner.Spec.Version)). // renames "app" → "server" for v1 - WithMutation(TracingSidecarMutation(owner.Spec.EnableTracing)). // uses AllContainers, order-insensitive - Build() +```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] ``` -For v2+, the backward compat mutation is inactive and `DebugLogging` sets the env var on `"app"`. For v1, `DebugLogging` -sets the env var on `"app"`, then `BackwardCompatV1Container` renames the container to `"server"` and resets its ports. -The env var survives because the rename does not touch `Env`. - -### Ordering with multiple backward compat mutations - -When multiple backward compat mutations exist, the same chained revert ordering from -[Revert mutations vs. forward mutations](#revert-mutations-vs-forward-mutations) applies: register the newest first -(closest to the baseline) and the oldest last. The additional constraint here is that feature mutations targeting a -container by name must come before the backward compat mutations that rename it. Feature mutations using broad selectors -can go anywhere. +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 -res, err := deployment.NewBuilder(BaseDeployment(owner)). // baseline is v3 - WithMutation(DebugLoggingMutation(owner.Spec.EnableDebugLogging)). // must come before backward compat renames - WithMutation(BackwardCompatV2Container(owner.Spec.Version)). // reverts v3 → v2 for < 3.0.0 - WithMutation(BackwardCompatV1Container(owner.Spec.Version)). // reverts v2 → v1 for < 2.0.0 - WithMutation(TracingSidecarMutation(owner.Spec.EnableTracing)). // order-insensitive (broad selector) - Build() +geV := feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{atLeast("2.0.0")}) +ltV := feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}) ``` -When you add a new version that changes the resource structure, update the baseline and insert the new backward compat -mutation before the existing ones. - -### What to avoid +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. -Do not work around the ordering problem by matching multiple names: +## Prefer Reverting Compat Mutations Over Forward Mutations -```go -// Anti-pattern: couples this mutation to knowledge of legacy naming. -m.EditContainers(selectors.ContainersNamed("app", "server"), func(ce *editors.ContainerEditor) error { - ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) - return nil -}) -``` +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: -This works today but breaks if a future version renames the container again. The mutation now needs to track every name -the container has ever had. Instead, target the baseline name and -[register the mutation before the backward compat rename](#register-name-specific-mutations-before-backward-compat-renames). +- **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. -### When a backward compat mutation replaces the entire container +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. -The carry-through property depends on the backward compat mutation only overwriting specific fields. If a backward -compat mutation replaces the entire container (sets all fields, not just `Name` and `Ports`), edits from earlier -mutations are lost. In that case, the mutation is effectively a full override and later mutations should target the -post-rename name via version gating rather than relying on ordering. +```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 + }, + } +} +``` -See the -[mutations-and-gating example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating) -for a working demonstration of these patterns. +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 Resource Dependencies +## Use Data Extraction and Guards for Intra-Component Dependencies -When one resource depends on data from another, use a data extractor on the first resource and a guard on the second. Do -not assume a resource is ready simply because it was registered earlier. +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(newCloudRole(owner)). +roleRes, _ := static.NewBuilder(cloudRole(app)). WithDataExtractor(func(obj uns.Unstructured) error { - arn, _, _ := unstructured.NestedString(obj.Object, "status", "arn") - roleARN = arn + roleARN, _, _ = unstructured.NestedString(obj.Object, "status", "arn") return nil }). Build() -bucketRes, _ := static.NewBuilder(newCloudBucket(owner)). +bucketRes, _ := static.NewBuilder(cloudBucket(app)). WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) { if roleARN == "" { return concepts.GuardStatusWithReason{ Status: concepts.GuardStatusBlocked, - Reason: "waiting for cloud provider role ARN", + Reason: "waiting for cloud role ARN", }, nil } return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil @@ -527,167 +345,205 @@ bucketRes, _ := static.NewBuilder(newCloudBucket(owner)). Build() ``` -The guard prevents the dependent resource from being applied until its precondition is met, and a blocked guard surfaces -as a `Blocked` condition reason so users can see why a resource has not been created yet. The shared variable -(`roleARN`) is scoped to the reconciliation call, which prevents state leakage between reconciles. +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 on every reconcile. If the extracted value it depends on is unstable (it can disappear, change, or -transiently become empty), the guard will re-block after the dependent resource has already been created. In most cases -this is not intentional. The resource is already running, but the guard now reports `Blocked` and skips reconciliation -for everything after it. - -Good candidates for guard conditions are values that appear once and remain stable: a status field written by a -controller (an ARN, a provisioned IP, a generated credential reference). Poor candidates are values that fluctuate -during normal operation, such as replica counts, transient annotations, or fields that get cleared during rolling -updates. - -If you genuinely need to react to a value disappearing after initial creation, that is a valid use case, but it should -be a deliberate design choice rather than an accidental side effect of choosing an unstable extraction target. +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 is ready, use a prerequisite on the dependent component rather than -orchestrating the ordering in the controller. +When one component cannot start until **another component** is ready, attach a prerequisite rather than orchestrating +ordering in the controller. ```go -dbComp, err := component.NewComponentBuilder(). - WithName("database"). - WithConditionType("DatabaseReady"). - WithResource(statefulSet). - Build() - -webComp, err := component.NewComponentBuilder(). - WithName("web-interface"). - WithConditionType("WebInterfaceReady"). - WithPrerequisite(component.DependsOn("DatabaseReady")). - WithResource(deployment). +frontendComp, err := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithPrerequisite(component.DependsOn("BackendReady")). + WithResource(frontendService). + WithResource(frontendDeployment). Build() ``` -The web-interface component will not reconcile any resources until the `DatabaseReady` condition on the owner is `True`. -Once the component passes through to normal reconciliation for the first time, the prerequisite is permanently passed -and never re-evaluated. +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. -This is the right tool when a component needs something to exist before it can be created. It is not the right tool for -ongoing health dependencies. If the database goes down after the web interface is already running, the web interface -component continues reconciling its own resources. The database's condition reflects the problem, and the web -interface's condition reflects its own health independently. Conflating the two would lose the granularity that separate -components provide. +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. -**Guards vs. prerequisites:** Guards are for resource dependencies within a single component (resource B depends on data -from resource A). Prerequisites are for startup dependencies between components. Guards re-evaluate every reconcile; -prerequisites evaluate only until the component's first successful reconciliation. +## Use Feature Gates for Optional Components and Conditional Resources -## Use Component Feature Gates for Optional Components +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. -When an entire component should only exist based on a feature flag, use a component-level feature gate rather than -conditionally building the component in the controller. +For an entire optional component, use a **component** gate: ```go -comp, err := component.NewComponentBuilder(). - WithName("monitoring"). - WithConditionType("MonitoringReady"). - WithFeatureGate(feature.NewVersionGate(owner.Spec.Version, nil).When(owner.Spec.MonitoringEnabled)). - WithResource(exporterDeployment). - WithResource(exporterService). +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 all of the component's resources and reports `True/Disabled`. When -re-enabled, the component reconciles normally. This is different from resource-level feature gating, which controls -individual resources within a component. Use a component gate when the entire component is conditional; use resource -gates when only some resources within the component are conditional. +When the gate is disabled the framework deletes the component's resources and reports `True/Disabled`. A disabled gate +takes precedence over suspension. -A disabled component gate takes precedence over suspension. If both the gate and suspension are active, the component is -treated as disabled (resources deleted), not suspended (resources scaled down). +For a single optional resource the component owns, use [`component.GatedBy`](component.md#feature-gates) on +`WithResource`: -## Mutations Describe Intent, Not Observation +```go +comp, _ := component.NewComponentBuilder(). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(frontendDeployment). + WithResource(tracingConfigMap, component.GatedBy(tracingGate)). // deleted when the gate is off + Build() +``` -Mutations operate on the desired object, not the server's current state. A mutation should be a pure function of the -owner spec and other static inputs available at build time. It should never try to read the resource's live cluster -state to decide what to write. +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. -This is not just a style preference. Within a single resource, the framework runs mutations **before** data extraction. -A data extractor registered on the same builder as a mutation will not have executed yet when that mutation runs. Any -closure variable populated by the extractor will still hold its zero value. +## Provide a User-Override Escape Hatch as the Last Mutation -Data extractors exist to pass observed state from an **earlier** resource to a **later** resource's guards and -mutations. They are not a mechanism for feeding a resource's own live state back into its own mutations. If you find -yourself wanting to do that, reconsider the design: the mutation is likely encoding observation rather than intent. +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. -A well-written mutation produces the same desired state for the same owner spec, regardless of what currently exists in -the cluster. This aligns with Server-Side Apply's declarative model and keeps the reconciliation loop predictable. +```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 + }, + } +} +``` -## Understand Participation Modes +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. -`ParticipationModeAuxiliary` 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 an auxiliary resource's health status -does not affect whether the component condition becomes Ready. +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 -comp, _ := component.NewComponentBuilder(). - WithName("web-interface"). - WithConditionType("WebInterfaceReady"). - WithResource(deployment). // Required for Ready - WithResource(metricsExporter, component.Auxiliary()). // Not required for Ready - Build() +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 + }, + } +} ``` -Use `Auxiliary` for resources that provide supporting functionality (metrics exporters, debug sidecars, optional -integrations) where their health should not block the component from reporting Ready. +## 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 + }, + } +} +``` -**Exception**: 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. +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. -## Use Feature Gating for Conditional Resources +## Name Mutations for Golden Introspection -When an entire resource should only exist based on a feature flag or version constraint, pass `component.GatedBy(gate)` -to `WithResource` rather than conditionally calling `WithResource()` in the controller. +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. -```go -tracingGate := feature.NewVersionGate(owner.Spec.Version, nil).When(owner.Spec.TracingEnabled) +## 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("web-interface"). - WithConditionType("WebInterfaceReady"). - WithResource(deployment). - WithResource(jaegerSidecar, component.GatedBy(tracingGate)). + WithName("frontend"). + WithConditionType("FrontendReady"). + WithResource(frontendDeployment). // required for Ready + WithResource(metricsExporter, component.Auxiliary()). // not required for Ready Build() ``` -When the gate evaluates to disabled, the framework deletes the resource if it exists. This handles the full lifecycle: -creation when enabled, deletion when disabled. Note that deletion is immediate on the next reconcile, so if you need -graceful decommissioning, handle that before disabling the gate. +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. Grace -periods give the component time to converge before the framework escalates the condition to `Degraded` or `Down`. +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("web-interface"). - WithConditionType("WebInterfaceReady"). - WithResource(deployment). + WithName("backend"). + WithConditionType("BackendReady"). + WithResource(backendStatefulSet). WithGracePeriod(5 * time.Minute). Build() ``` -Set the grace period based on how long the resource legitimately takes to converge. A deployment 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. +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 (like `ClusterRole` or `ClusterRoleBinding`), the -framework cannot set an owner reference because Kubernetes does not allow cross-scope ownership. The framework detects -this, skips setting the owner reference, and emits an Info log noting the skipped reference and its garbage collection -implications. +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. -This means cluster-scoped resources will not be garbage collected when the owner is deleted. Handle cleanup explicitly -using `Delete: true` in resource options or a finalizer on the owner CRD: +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(). @@ -697,27 +553,59 @@ comp, _ := component.NewComponentBuilder(). 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 in monitoring dashboards. Name them for the person or system -consuming that output, not for the internal implementation. +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`. -**Prefer**: +A condition named `DeploymentReconciled` tells a user nothing about which capability is affected. `BackendReady` does. -- `WebInterfaceReady` -- `DatabaseReady` -- `MigrationComplete` +## Write Golden Tests for Every Supported Version -**Avoid**: +Every supported version should have a golden snapshot. When you update the baseline, golden tests prove that older +versions still render the object they did before, and that the change touched only the version you intended. -- `DeploymentReconciled` -- `StatefulSetHealthy` -- `JobFinished` +```go +func TestBackendShape(t *testing.T) { + for _, version := range []string{"1.9.0", "2.0.0", "2.1.0"} { + t.Run(version, func(t *testing.T) { + app := &v1alpha1.WebApp{Spec: v1alpha1.WebAppSpec{Version: version}} + res, err := buildBackend(app) + require.NoError(t, err) + golden.AssertYAML(t, "testdata/backend-"+version+".yaml", res, golden.Update(*update)) + }) + } +} +``` -The audience cares about the feature, not the Kubernetes resource type backing it. A condition named -`DeploymentReconciled` tells a user nothing about what capability is affected. +Run `go test ./path -update` to regenerate after a deliberate baseline change, then review the diff: the current +version's golden updates; older version goldens should not. If a baseline change accidentally breaks a compat mutation, +the diff shows exactly what shifted. For sweeping every supported version and asserting mutation coverage across the +matrix, see [testing.md](testing.md). ## 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/docs/testing.md b/docs/testing.md index ecaa9fa2..dddd919b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,15 +1,16 @@ # Testing -The framework ships two test-only packages for asserting the desired state your resources and components produce: +This page covers the two test-only packages the framework ships for asserting the desired state your resources and +components produce: `pkg/testing/golden` for single-build snapshot tests and `pkg/testing/goldengen` for version-matrix +golden generation. It is aimed at anyone writing tests for primitives or components built with this framework. -- `pkg/testing/golden` snapshots a single build to a YAML golden file and compares against it on every run. -- `pkg/testing/goldengen` sweeps a version universe over one or more fixtures, classifies the swept versions into gating - regimes, generates the minimal set of goldens covering them, asserts which mutations fire at which version, and proves - every registered mutation is accounted for. +## Which tool to use -Reach for `golden` when you want to pin the output of one build. Reach for `goldengen` when a resource carries -version-gated mutations and you want one golden per behavior rather than one per version, with the gating asserted -explicitly. +| Situation | Tool | +| --------------------------------------------------------------- | ------------------------------------------------ | +| Pin the output of one resource or component build | `golden` | +| Assert gating across many versions for a version-gated resource | `goldengen` | +| Generate goldens from a tool outside a test body | `golden.Serialize` / `golden.SerializeComponent` | Both packages are opt-in and import nothing into the reconcile path. A consumer that does not import them pays nothing. @@ -19,28 +20,81 @@ Both packages are opt-in and import nothing into the reconcile path. A consumer 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. Wire -a `-update` flag to regenerate the golden when the desired state legitimately changes. +`AssertYAML` previews a built primitive, serializes it, and fails the test on any difference from the golden file. ```go var update = flag.Bool("update", false, "update golden files") func TestDeploymentGolden(t *testing.T) { - res, err := deployment.NewBuilder(baseDeployment()). - WithMutation(features.DebugLoggingMutation(true)). - Build() - require.NoError(t, err) + res, err := deployment.NewBuilder(baseDeployment()). + WithMutation(features.DebugLoggingMutation(true)). + Build() + require.NoError(t, err) - golden.AssertYAML(t, "testdata/deployment.yaml", res, - golden.WithScheme(scheme), golden.Update(*update)) + golden.AssertYAML(t, "testdata/deployment.yaml", res, + golden.WithScheme(scheme), golden.Update(*update)) } ``` -`golden.WithScheme(scheme)` resolves `apiVersion` and `kind` for objects that do not populate `TypeMeta`. Without it, -serialization of such an object fails. `golden.Update(*update)` overwrites the golden file (creating intermediate -directories) instead of comparing, so `go test ./... -update` refreshes every golden in one pass. +`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 @@ -49,39 +103,49 @@ stream (`---` separated, in apply order). ```go func TestComponentGolden(t *testing.T) { - c, err := buildComponent(owner) - require.NoError(t, err) + c, err := buildComponent(owner) + require.NoError(t, err) - golden.AssertComponentYAML(t, "testdata/component.yaml", c, - golden.WithScheme(scheme), golden.Update(*update)) + golden.AssertComponentYAML(t, "testdata/component.yaml", c, + golden.WithScheme(scheme), golden.Update(*update)) } ``` -Both helpers have non-`testing.T` variants, `CompareYAML` and `CompareComponentYAML`, that return a `*MismatchError` -(carrying a unified diff) instead of failing a test, for use outside a test body. +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 -### Serialize out of band +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: -When you need the canonical YAML bytes directly, for example to feed a custom comparison or to generate goldens from a -tool, call the serializers the assertions use: +- `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 +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. ## Version matrix generation -A resource with version-gated mutations behaves differently across versions, but not at every version: it changes only -where a gate flips. Asserting one golden per version is wasteful and obscures where behavior actually changes. +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` sweeps the versions you supply, groups them 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). -The walkthrough below follows it. +The walkthrough below follows it directly. ### Declare the matrix @@ -90,30 +154,30 @@ function accepts). ```go var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ - Dir: "testdata/version_matrix", - Versions: []string{"8.7.0", "8.8.2", "8.9.0"}, - Fixtures: []goldengen.Fixture[*app.ExampleApp]{{ - Name: "default", - Spec: defaultCluster(), - Requires: []goldengen.Expect{ - {Name: "ContainerImage"}, - {Name: "ClusterEnv/Pre89", For: "8.8.2"}, - {Name: "ClusterEnv/Unified89", For: "8.9.0"}, - }, - Forbids: []goldengen.Expect{ - {Name: "ClusterEnv/Unified89", For: "8.8.2"}, - {Name: "ClusterEnv/Pre89", For: "8.9.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 - }, + Dir: "testdata/version_matrix", + Versions: []string{"8.7.0", "8.8.2", "8.9.0"}, + Fixtures: []goldengen.Fixture[*app.ExampleApp]{{ + Name: "default", + Spec: defaultCluster(), + Requires: []goldengen.Expect{ + {Name: "ContainerImage"}, + {Name: "ClusterEnv/Pre89", For: "8.8.2"}, + {Name: "ClusterEnv/Unified89", For: "8.9.0"}, + }, + Forbids: []goldengen.Expect{ + {Name: "ClusterEnv/Unified89", For: "8.8.2"}, + {Name: "ClusterEnv/Pre89", For: "8.9.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 + }, }) ``` @@ -131,6 +195,11 @@ The fields: with `goldengen.Resource(res, scheme)` or a built component with `goldengen.Component(comp, scheme)`. Both delegate rendering to `golden.Serialize` / `golden.SerializeComponent`. +`goldengen.Resource` requires that the primitive satisfies both `concepts.MutationInspector` (for `RegisteredMutations` +and `FiringSet`) and `concepts.Previewable` (for `Preview`). All built-in primitives satisfy both through +`generic.BaseResource`. 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: @@ -139,8 +208,8 @@ Wire a `-update` flag through `WithUpdate` and call `Run` from a normal test: var update = flag.Bool("update", false, "update golden files") func TestVersionMatrix(t *testing.T) { - gen.WithUpdate(*update) - gen.Run(t) + gen.WithUpdate(*update) + gen.Run(t) } ``` @@ -148,8 +217,8 @@ func TestVersionMatrix(t *testing.T) { 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 # generate -go test ./examples/version-matrix/ # verify +go test ./examples/version-matrix/ -run TestVersionMatrix -update +go test ./examples/version-matrix/ ``` ### Firing-set classification @@ -201,7 +270,7 @@ forbidden at `8.8.2`, which locks the gate to exactly the `8.9.0` boundary rathe ```go func TestMain(m *testing.M) { - os.Exit(gen.AssertComplete(m.Run())) + os.Exit(gen.AssertComplete(m.Run())) } ``` @@ -251,9 +320,9 @@ function stays in code. `LoadMatrix` reads the file and returns a ready-to-run ` ```go func LoadMatrix[T any]( - path string, - newSpec func() T, - build func(version string, spec T) (Unit, error), + path string, + newSpec func() T, + build func(version string, spec T) (Unit, error), ) (Config[T], error) ``` @@ -295,8 +364,8 @@ fixtures: ```go cfg, err := goldengen.LoadMatrix("testdata/matrix.yaml", - func() *app.ExampleApp { return &app.ExampleApp{} }, - buildUnit) + func() *app.ExampleApp { return &app.ExampleApp{} }, + buildUnit) require.NoError(t, err) gen := goldengen.New(cfg).WithUpdate(*update) From 34e75a29154e45cee6bb970ea10f5fa4ba077de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:46:44 +0200 Subject: [PATCH 08/37] example: genericize version-matrix naming; sync testing docs Replace the domain-flavored version-gating scenario (8.x versions, ClusterEnv Pre89/Unified89 with gossip/raft discovery) with a neutral one: versions 1.0.0/1.5.0/2.0.0 and PeerDiscovery/PreV2 and PeerDiscovery/V2 mutations gated at the 2.0.0 boundary. Regenerate the goldens and manifest, and update the testing guide to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 62 +++++++++---------- examples/version-matrix/README.md | 16 ++--- .../version-matrix/resources/statefulset.go | 30 ++++----- .../default/{8.9.0.yaml => 1.0.0.yaml} | 6 +- .../default/{8.7.0.yaml => 2.0.0.yaml} | 6 +- .../testdata/version_matrix/manifest.yaml | 14 ++--- .../version-matrix/version_matrix_test.go | 12 ++-- 7 files changed, 73 insertions(+), 73 deletions(-) rename examples/version-matrix/testdata/version_matrix/default/{8.9.0.yaml => 1.0.0.yaml} (80%) rename examples/version-matrix/testdata/version_matrix/default/{8.7.0.yaml => 2.0.0.yaml} (79%) diff --git a/docs/testing.md b/docs/testing.md index dddd919b..f732365a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -155,18 +155,18 @@ function accepts). ```go var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ Dir: "testdata/version_matrix", - Versions: []string{"8.7.0", "8.8.2", "8.9.0"}, + 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: "ClusterEnv/Pre89", For: "8.8.2"}, - {Name: "ClusterEnv/Unified89", For: "8.9.0"}, + {Name: "PeerDiscovery/PreV2", For: "1.5.0"}, + {Name: "PeerDiscovery/V2", For: "2.0.0"}, }, Forbids: []goldengen.Expect{ - {Name: "ClusterEnv/Unified89", For: "8.8.2"}, - {Name: "ClusterEnv/Pre89", For: "8.9.0"}, + {Name: "PeerDiscovery/V2", For: "1.5.0"}, + {Name: "PeerDiscovery/PreV2", For: "2.0.0"}, }, }}, Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) { @@ -227,26 +227,26 @@ The firing set at a version is the set of registered mutations whose gate is ena 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 `8.7.0`, `8.8.2`, `8.9.0` collapses to two regimes: +In the example, the universe `1.0.0`, `1.5.0`, `2.0.0` collapses to two regimes: ```mermaid flowchart LR - v1["8.7.0"] --> r1 - v2["8.8.2"] --> r1 - v3["8.9.0"] --> r2 - r1["regime: ContainerImage + ClusterEnv/Pre89
golden: default/8.7.0.yaml"] - r2["regime: ContainerImage + ClusterEnv/Unified89
golden: default/8.9.0.yaml"] + 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"] ``` -`8.7.0` and `8.8.2` fire the same set, so they share one golden; `8.9.0` crosses the `ClusterEnv` boundary into its own -regime. Two goldens cover three versions, and adding more versions inside an existing regime adds no goldens. +`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/8.9.0.yaml` is named for the first version at which the -unified-discovery regime takes effect. List versions ascending unless you have a specific reason not to. +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 @@ -260,8 +260,8 @@ set it must be a version drawn from `Versions`. | `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 `ClusterEnv/Unified89` is required at `8.9.0` and -forbidden at `8.8.2`, which locks the gate to exactly the `8.9.0` boundary rather than merely "fires somewhere". +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 @@ -295,19 +295,19 @@ representative version, the versions it covers, and the shared firing set. fixtures: - name: default regimes: - - representative: 8.7.0 + - representative: 1.0.0 versions: - - 8.7.0 - - 8.8.2 + - 1.0.0 + - 1.5.0 firing: - - ClusterEnv/Pre89 - ContainerImage - - representative: 8.9.0 + - PeerDiscovery/PreV2 + - representative: 2.0.0 versions: - - 8.9.0 + - 2.0.0 firing: - - ClusterEnv/Unified89 - 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 @@ -336,9 +336,9 @@ from an external file under `specFile:` (resolved relative to the matrix file), ```yaml dir: testdata/version_matrix versions: - - "8.7.0" - - "8.8.2" - - "8.9.0" + - "1.0.0" + - "1.5.0" + - "2.0.0" exclude: [] fixtures: - name: default @@ -349,13 +349,13 @@ fixtures: name: demo namespace: default spec: - version: 8.7.0 + version: 1.0.0 requires: - { name: ContainerImage } - - { name: ClusterEnv/Pre89, for: "8.8.2" } - - { name: ClusterEnv/Unified89, for: "8.9.0" } + - { name: PeerDiscovery/PreV2, for: "1.5.0" } + - { name: PeerDiscovery/V2, for: "2.0.0" } forbids: - - { name: ClusterEnv/Unified89, for: "8.8.2" } + - { name: PeerDiscovery/V2, for: "1.5.0" } - name: tls specFile: fixtures/tls.yaml # external custom resource requires: diff --git a/examples/version-matrix/README.md b/examples/version-matrix/README.md index 4dafb5f2..8103d1e3 100644 --- a/examples/version-matrix/README.md +++ b/examples/version-matrix/README.md @@ -11,15 +11,15 @@ the gating, and proving every registered mutation is accounted for. version universe produces a distinct golden per gating regime instead of one golden per version. - **Version-gated mutations**: - `ContainerImage` has no gate, so it fires at every version and anchors the always-on part of the firing set. - - `ClusterEnv/Pre89` fires for versions `< 8.9.0` (legacy gossip discovery). - - `ClusterEnv/Unified89` fires for versions `>= 8.9.0` (unified raft discovery). -- **Firing-set classification**: The version universe `8.7.0`, `8.8.2`, `8.9.0` collapses to two regimes: - `{ContainerImage, ClusterEnv/Pre89}` covering `8.7.0` and `8.8.2`, and `{ContainerImage, ClusterEnv/Unified89}` - covering `8.9.0`. Only two goldens are written, one per regime, named by the regime's representative version. + - `PeerDiscovery/PreV2` fires for versions `< 2.0.0` (legacy peer-discovery format). + - `PeerDiscovery/V2` fires for versions `>= 2.0.0` (peer-discovery format introduced in 2.0.0). +- **Firing-set classification**: The version universe `1.0.0`, `1.5.0`, `2.0.0` collapses to two regimes: + `{ContainerImage, PeerDiscovery/PreV2}` covering `1.0.0` and `1.5.0`, and `{ContainerImage, PeerDiscovery/V2}` + covering `2.0.0`. Only two goldens are written, one per regime, named by the regime's representative version. - **Ascending version order**: Listing `Versions` ascending puts each regime's representative on the lower inclusive boundary of its gating range, so the golden's filename marks exactly where the regime begins. - **Gating assertions**: `Requires`/`Forbids` pin which mutation fires (or does not) at which version. The boundary is - asserted from both sides: `ClusterEnv/Unified89` is required at `8.9.0` and forbidden at `8.8.2`. + asserted from both sides: `PeerDiscovery/V2` is required at `2.0.0` and forbidden at `1.5.0`. - **Completeness accounting**: `TestMain` calls `gen.AssertComplete(m.Run())`, which fails the package if any registered mutation is neither required by a fixture nor listed in `Exclude`. Adding a fourth mutation without asserting it would break this test. @@ -29,8 +29,8 @@ the gating, and proving every registered mutation is accounted for. ``` testdata/version_matrix/ manifest.yaml # per-fixture regimes: representative, versions, firing-set - default/8.7.0.yaml # regime representative for { ContainerImage, ClusterEnv/Pre89 } - default/8.9.0.yaml # regime representative for { ContainerImage, ClusterEnv/Unified89 } + default/1.0.0.yaml # regime representative for { ContainerImage, PeerDiscovery/PreV2 } + default/2.0.0.yaml # regime representative for { ContainerImage, PeerDiscovery/V2 } ``` ## Running diff --git a/examples/version-matrix/resources/statefulset.go b/examples/version-matrix/resources/statefulset.go index 6aefdecf..fb416529 100644 --- a/examples/version-matrix/resources/statefulset.go +++ b/examples/version-matrix/resources/statefulset.go @@ -78,28 +78,28 @@ func ContainerImageMutation(owner *app.ExampleApp) statefulset.Mutation { } } -// ClusterEnvPre89Mutation sets the pre-8.9 cluster-coordination environment -// variable. It fires only for versions below 8.9.0, where the unified protocol is -// not yet available. -func ClusterEnvPre89Mutation(version string) statefulset.Mutation { +// PeerDiscoveryPreV2Mutation sets the legacy peer-discovery environment variable. +// It fires only for versions below 2.0.0, where the newer discovery format is not +// yet available. +func PeerDiscoveryPreV2Mutation(version string) statefulset.Mutation { return statefulset.Mutation{ - Name: "ClusterEnv/Pre89", - Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint("< 8.9.0")}), + Name: "PeerDiscovery/PreV2", + Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint("< 2.0.0")}), Mutate: func(m *statefulset.Mutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "legacy-gossip"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "PEER_DISCOVERY", Value: "legacy"}) return nil }, } } -// ClusterEnvUnified89Mutation sets the unified cluster-coordination environment -// variable introduced in 8.9.0. It fires only for versions at or above 8.9.0. -func ClusterEnvUnified89Mutation(version string) statefulset.Mutation { +// PeerDiscoveryV2Mutation sets the peer-discovery environment variable introduced +// in 2.0.0. It fires only for versions at or above 2.0.0. +func PeerDiscoveryV2Mutation(version string) statefulset.Mutation { return statefulset.Mutation{ - Name: "ClusterEnv/Unified89", - Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint(">= 8.9.0")}), + Name: "PeerDiscovery/V2", + Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint(">= 2.0.0")}), Mutate: func(m *statefulset.Mutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "unified-raft"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "PEER_DISCOVERY", Value: "v2"}) return nil }, } @@ -111,7 +111,7 @@ func ClusterEnvUnified89Mutation(version string) statefulset.Mutation { func NewStatefulSetResource(owner *app.ExampleApp) (*statefulset.Resource, error) { return statefulset.NewBuilder(BaseStatefulSet(owner)). WithMutation(ContainerImageMutation(owner)). - WithMutation(ClusterEnvPre89Mutation(owner.Spec.Version)). - WithMutation(ClusterEnvUnified89Mutation(owner.Spec.Version)). + WithMutation(PeerDiscoveryPreV2Mutation(owner.Spec.Version)). + WithMutation(PeerDiscoveryV2Mutation(owner.Spec.Version)). Build() } diff --git a/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml b/examples/version-matrix/testdata/version_matrix/default/1.0.0.yaml similarity index 80% rename from examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml rename to examples/version-matrix/testdata/version_matrix/default/1.0.0.yaml index 9c0aa59f..2dfb0638 100644 --- a/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml +++ b/examples/version-matrix/testdata/version_matrix/default/1.0.0.yaml @@ -17,9 +17,9 @@ spec: spec: containers: - env: - - name: CLUSTER_DISCOVERY - value: unified-raft - image: example/db:8.9.0 + - name: PEER_DISCOVERY + value: legacy + image: example/db:1.0.0 name: db resources: {} updateStrategy: {} diff --git a/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml b/examples/version-matrix/testdata/version_matrix/default/2.0.0.yaml similarity index 79% rename from examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml rename to examples/version-matrix/testdata/version_matrix/default/2.0.0.yaml index fee8dce3..95b3c866 100644 --- a/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml +++ b/examples/version-matrix/testdata/version_matrix/default/2.0.0.yaml @@ -17,9 +17,9 @@ spec: spec: containers: - env: - - name: CLUSTER_DISCOVERY - value: legacy-gossip - image: example/db:8.7.0 + - name: PEER_DISCOVERY + value: v2 + image: example/db:2.0.0 name: db resources: {} updateStrategy: {} diff --git a/examples/version-matrix/testdata/version_matrix/manifest.yaml b/examples/version-matrix/testdata/version_matrix/manifest.yaml index 24c3413f..1724b039 100644 --- a/examples/version-matrix/testdata/version_matrix/manifest.yaml +++ b/examples/version-matrix/testdata/version_matrix/manifest.yaml @@ -2,15 +2,15 @@ fixtures: - name: default regimes: - firing: - - ClusterEnv/Pre89 - ContainerImage - representative: 8.7.0 + - PeerDiscovery/PreV2 + representative: 1.0.0 versions: - - 8.7.0 - - 8.8.2 + - 1.0.0 + - 1.5.0 - firing: - - ClusterEnv/Unified89 - ContainerImage - representative: 8.9.0 + - PeerDiscovery/V2 + representative: 2.0.0 versions: - - 8.9.0 + - 2.0.0 diff --git a/examples/version-matrix/version_matrix_test.go b/examples/version-matrix/version_matrix_test.go index 1e8d00d3..1bb5fc3e 100644 --- a/examples/version-matrix/version_matrix_test.go +++ b/examples/version-matrix/version_matrix_test.go @@ -50,18 +50,18 @@ func defaultCluster() *app.ExampleApp { // regime's representative lands on the lower inclusive boundary of its gating range. var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ Dir: "testdata/version_matrix", - Versions: []string{"8.7.0", "8.8.2", "8.9.0"}, + 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"}, // fires at every version - {Name: "ClusterEnv/Pre89", For: "8.8.2"}, // legacy discovery before 8.9 - {Name: "ClusterEnv/Unified89", For: "8.9.0"}, // unified discovery from 8.9 + {Name: "ContainerImage"}, // fires at every version + {Name: "PeerDiscovery/PreV2", For: "1.5.0"}, // legacy format before 2.0.0 + {Name: "PeerDiscovery/V2", For: "2.0.0"}, // new format from 2.0.0 }, Forbids: []goldengen.Expect{ - {Name: "ClusterEnv/Unified89", For: "8.8.2"}, // not before the boundary - {Name: "ClusterEnv/Pre89", For: "8.9.0"}, // not after the boundary + {Name: "PeerDiscovery/V2", For: "1.5.0"}, // not before the boundary + {Name: "PeerDiscovery/PreV2", For: "2.0.0"}, // not after the boundary }, }}, Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) { From effe89a0db04602be675b9d49f29a687ef3dd844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:49:26 +0200 Subject: [PATCH 09/37] docs: remove em dashes from rewritten pages Replace em dashes with colons, parentheses, or sentence breaks as the context requires, per the project writing style. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guidelines.md | 8 ++++---- docs/primitives/clusterrole.md | 10 +++++----- docs/primitives/clusterrolebinding.md | 9 ++++----- docs/primitives/role.md | 14 +++++++------- docs/primitives/rolebinding.md | 16 ++++++++-------- docs/primitives/serviceaccount.md | 6 +++--- docs/primitives/unstructured.md | 8 ++++---- 7 files changed, 35 insertions(+), 36 deletions(-) diff --git a/docs/guidelines.md b/docs/guidelines.md index a8fd48d8..70edd667 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -256,10 +256,10 @@ to every name the container has ever had. The primitives overview covers the 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 +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). diff --git a/docs/primitives/clusterrole.md b/docs/primitives/clusterrole.md index 5b8f6a25..19657583 100644 --- a/docs/primitives/clusterrole.md +++ b/docs/primitives/clusterrole.md @@ -7,8 +7,8 @@ object metadata within the component lifecycle. 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 + 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 @@ -18,8 +18,8 @@ object metadata within the component lifecycle. | **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 | +| **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). @@ -74,7 +74,7 @@ func CRDAccessMutation(version string, manageCRDs bool) clusterrole.Mutation { } ``` -For boolean conditions, chain `.When()` on the gate — see +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). diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index 94c3a6fa..bc549d36 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -7,8 +7,7 @@ metadata within the component lifecycle. 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 + 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 @@ -19,8 +18,8 @@ metadata within the component lifecycle. | **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 | +| **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). @@ -82,7 +81,7 @@ func ExtraSubjectMutation(version string, enabled bool) clusterrolebinding.Mutat } ``` -For boolean conditions, chain `.When()` on the gate — see +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). diff --git a/docs/primitives/role.md b/docs/primitives/role.md index 25c8b65f..29664c65 100644 --- a/docs/primitives/role.md +++ b/docs/primitives/role.md @@ -5,12 +5,12 @@ 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 | +| 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. @@ -67,7 +67,7 @@ func SecretAccessMutation(version string, enabled bool) role.Mutation { } ``` -For boolean conditions, chain `.When()` on the gate — see +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). diff --git a/docs/primitives/rolebinding.md b/docs/primitives/rolebinding.md index 39b63d43..0017bfa3 100644 --- a/docs/primitives/rolebinding.md +++ b/docs/primitives/rolebinding.md @@ -5,13 +5,13 @@ 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 | +| 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. @@ -69,7 +69,7 @@ func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutatio } ``` -For boolean conditions, chain `.When()` on the gate — see +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). diff --git a/docs/primitives/serviceaccount.md b/docs/primitives/serviceaccount.md index 5fd849c8..7d936ee3 100644 --- a/docs/primitives/serviceaccount.md +++ b/docs/primitives/serviceaccount.md @@ -9,8 +9,8 @@ flag, and object metadata within the component lifecycle. | -------------------- | --------------------------------------------------------------------------------------------------- | | **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 | +| **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. @@ -53,7 +53,7 @@ func BaseTokenMutation(version string) serviceaccount.Mutation { } ``` -For boolean conditions, chain `.When()` on the gate — see +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). diff --git a/docs/primitives/unstructured.md b/docs/primitives/unstructured.md index b76c5e71..93368655 100644 --- a/docs/primitives/unstructured.md +++ b/docs/primitives/unstructured.md @@ -7,12 +7,12 @@ definition at compile time: external CRDs, Crossplane resources, or any object k 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 +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 +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 +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 From 6046eaf43e7c5073eee5c605a394b203d954cf67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:57:21 +0200 Subject: [PATCH 10/37] docs: strengthen landing page pitch and move navigation above it Lead with the navigation (Start here) above the prose, and rewrite the value proposition to frame the framework's full purpose (composing operators from reusable, testable layers) rather than a single status-handling pain point. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/index.md | 84 +++++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/docs/index.md b/docs/index.md index 4a2467b3..23b9cf04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,16 +2,53 @@ A Go framework for building Kubernetes operators that stay maintainable as they grow. +## Start here + +New to the framework? Start with **Getting Started**. Already building and looking for patterns? Read **Guidelines**. + +
+ + +- :material-rocket-launch-outline: **[Getting Started](getting-started.md)** + + Build your first component step by step. + +- :material-cube-outline: **[Component](component.md)** + + Lifecycle, status model, and reconciliation phases. + +- :material-shape-outline: **[Primitives](primitives.md)** + + Typed wrappers over Kubernetes resources with builders, mutators, and feature gating. + +- :material-source-branch: **[Custom Resources](custom-resource.md)** + + Build custom resource wrappers with `pkg/generic`. + +- :material-book-open-variant: **[Guidelines](guidelines.md)** + + Patterns for structuring operators well. + +- :material-test-tube: **[Testing](testing.md)** + + Golden snapshots and version-matrix golden generation. + +
+ ## Why this exists -Operators tend to accumulate the same problems. Status conditions are assembled by hand, and aggregating them into a -single owner condition without provoking update conflicts is fiddly to get right. Reconcilers grow into fat, -hard-to-test functions that mix construction, ordering, health checks, and status writes. Version-gating logic ends up -scattered through the reconcile path as conditionals that are easy to break and hard to review. +A Kubernetes operator does far more than create resources. For every resource it manages, a controller has to construct +the desired object, apply it without overwriting fields it does not own, decide whether the resource is healthy, fold +that health into a status condition on the owner, and adapt behavior to feature flags and the application versions it +supports. Written by hand, this logic collects in the reconciler until it is large, repetitive, and hard to test, and +the part you actually care about, what your operator does, is buried under mechanics that every operator repeats. -This framework moves that work into two reusable layers, **components** and **resource primitives**, that sit between -your reconciler and the Kubernetes objects it manages. Reconciliation mechanics, health aggregation, and feature gating -live in the framework, so controllers stay thin and the version-specific behavior lives in named, testable mutations. +This framework gives you two reusable layers, **components** and **resource primitives**, that sit between your +reconciler and the Kubernetes objects it manages. You declare the desired state of each resource and the behavior that +varies by flag or version. The framework handles server-side apply, per-resource health, aggregation into a single owner +condition without update conflicts, lifecycle (grace periods, suspension, prerequisites, guards), and feature gating. +Controllers stay thin, version-specific behavior lives in small named mutations you can test in isolation, and you keep +full control where it matters. ## Key features @@ -55,36 +92,3 @@ return comp.Reconcile(ctx, recCtx) [Getting Started](getting-started.md) walks through building `deployResource` and `cmResource` and wiring the reconcile loop end to end. - -## Where to go next - -New to the framework? Start with **Getting Started**. Already building and looking for patterns? Read **Guidelines**. - -
- - -- :material-rocket-launch-outline: **[Getting Started](getting-started.md)** - - Build your first component step by step. - -- :material-cube-outline: **[Component](component.md)** - - Lifecycle, status model, and reconciliation phases. - -- :material-shape-outline: **[Primitives](primitives.md)** - - Typed wrappers over Kubernetes resources with builders, mutators, and feature gating. - -- :material-source-branch: **[Custom Resources](custom-resource.md)** - - Build custom resource wrappers with `pkg/generic`. - -- :material-book-open-variant: **[Guidelines](guidelines.md)** - - Patterns for structuring operators well. - -- :material-test-tube: **[Testing](testing.md)** - - Golden snapshots and version-matrix golden generation. - -
From 312af067b2827bdff5b80c31093602dcef1e4cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:17:32 +0200 Subject: [PATCH 11/37] docs: adopt feature.NewBooleanGate across docs and examples Use the new NewBooleanGate(enabled) constructor in the Primitives Overview boolean-gating section, the getting-started tutorial, and the mutations-and-gating example (tracing, debug-logging, and metrics-config mutations) in place of the verbose NewVersionGate("", nil).When(enabled) form. Drop the now-unused version parameter from MetricsConfigMutation. Version-gated examples are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/getting-started.md | 6 +++--- docs/primitives.md | 11 ++++++----- examples/mutations-and-gating/README.md | 4 ++-- .../mutations-and-gating/features/debug_logging.go | 2 +- .../mutations-and-gating/features/metrics_config.go | 4 ++-- .../features/metrics_config_test.go | 2 +- .../mutations-and-gating/features/tracing_sidecar.go | 2 +- examples/mutations-and-gating/resources/configmap.go | 2 +- .../mutations-and-gating/resources/configmap_test.go | 2 +- 9 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index cef81902..51ce9783 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -176,8 +176,8 @@ func BaseDeployment(owner *app.ExampleApp) *appsv1.Deployment { ``` Now the mutation. It targets the container named `app` and sets an environment variable. The gate is built with -`feature.NewVersionGate("", nil).When(enabled)`: passing an empty version and no constraints, then `.When(enabled)`, -yields a gate that fires purely on the boolean. When `enabled` is `false`, the framework skips the edit. +`feature.NewBooleanGate(enabled)`: a gate with no version constraints whose result is driven purely by the boolean. When +`enabled` is `false`, the framework skips the edit. ```go package features @@ -194,7 +194,7 @@ import ( func DebugLoggingMutation(enabled bool) deployment.Mutation { return deployment.Mutation{ Name: "DebugLogging", - Feature: feature.NewVersionGate("", nil).When(enabled), + Feature: feature.NewBooleanGate(enabled), Mutate: func(m *deployment.Mutator) error { m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) diff --git a/docs/primitives.md b/docs/primitives.md index 0a67a071..1f734200 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -204,17 +204,18 @@ earlier ones. ## Boolean-Gated Mutations -A mutation can be enabled by a runtime condition rather than a version. Build a gate with no version constraints and add -boolean conditions through `When`: +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.NewVersionGate("", nil).When(len(spec.ExtraEnv) > 0) +gate := feature.NewBooleanGate(len(spec.ExtraEnv) > 0) ``` -`When` is additive: every value passed must be true for the gate to enable. An empty current version and `nil` -constraints mean only the boolean conditions decide. This is the idiomatic way to make a mutation conditional on the +`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 diff --git a/examples/mutations-and-gating/README.md b/examples/mutations-and-gating/README.md index b7cb76d2..485150e7 100644 --- a/examples/mutations-and-gating/README.md +++ b/examples/mutations-and-gating/README.md @@ -10,8 +10,8 @@ manages two resources: a Deployment and a ConfigMap. - **Version-gated backward compat mutation**: `BackwardCompatV1Container` activates for versions `< 2.0.0` and rolls the baseline back to the v1 layout (container named "server", HTTP port only). Uses a `semver.Constraint` as a `feature.VersionConstraint`. The `BackwardCompat` prefix makes the pattern immediately recognizable. -- **Boolean-gated mutation**: `TracingSidecarMutation` injects a Jaeger sidecar. It is gated via `.When(enabled)`, so - the sidecar is added only when tracing is on and removed when it is off. +- **Boolean-gated mutation**: `TracingSidecarMutation` injects a Jaeger sidecar. It is gated with + `feature.NewBooleanGate(enabled)`, so the sidecar is added only when tracing is on and removed when it is off. - **Mutation ordering for container name stability**: `DebugLoggingMutation` targets `ContainerNamed("app")` and is registered before `BackwardCompatV1Container`. This ensures it always sees the baseline name, even though the backward compat mutation renames the container for older versions. The env var edit carries through the rename because the diff --git a/examples/mutations-and-gating/features/debug_logging.go b/examples/mutations-and-gating/features/debug_logging.go index d58c3b24..02af9743 100644 --- a/examples/mutations-and-gating/features/debug_logging.go +++ b/examples/mutations-and-gating/features/debug_logging.go @@ -16,7 +16,7 @@ import ( func DebugLoggingMutation(enabled bool) deployment.Mutation { return deployment.Mutation{ Name: "DebugLogging", - Feature: feature.NewVersionGate("any", nil).When(enabled), + Feature: feature.NewBooleanGate(enabled), Mutate: func(m *deployment.Mutator) error { m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) diff --git a/examples/mutations-and-gating/features/metrics_config.go b/examples/mutations-and-gating/features/metrics_config.go index 492c3095..02d3a587 100644 --- a/examples/mutations-and-gating/features/metrics_config.go +++ b/examples/mutations-and-gating/features/metrics_config.go @@ -7,10 +7,10 @@ import ( // MetricsConfigMutation adds a Prometheus metrics section to app.yaml. // It is boolean-gated on the enableMetrics flag. -func MetricsConfigMutation(version string, enableMetrics bool) configmap.Mutation { +func MetricsConfigMutation(enableMetrics bool) configmap.Mutation { return configmap.Mutation{ Name: "metrics-config", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), + Feature: feature.NewBooleanGate(enableMetrics), Mutate: func(m *configmap.Mutator) error { m.MergeYAML("app.yaml", ` metrics: diff --git a/examples/mutations-and-gating/features/metrics_config_test.go b/examples/mutations-and-gating/features/metrics_config_test.go index 8960a68e..546f81c4 100644 --- a/examples/mutations-and-gating/features/metrics_config_test.go +++ b/examples/mutations-and-gating/features/metrics_config_test.go @@ -22,7 +22,7 @@ func TestMetricsConfigMutation(t *testing.T) { } res, err := configmap.NewBuilder(base). - WithMutation(features.MetricsConfigMutation("1.0.0", true)). + WithMutation(features.MetricsConfigMutation(true)). Build() require.NoError(t, err) diff --git a/examples/mutations-and-gating/features/tracing_sidecar.go b/examples/mutations-and-gating/features/tracing_sidecar.go index 6b77ccd1..b47b6940 100644 --- a/examples/mutations-and-gating/features/tracing_sidecar.go +++ b/examples/mutations-and-gating/features/tracing_sidecar.go @@ -11,7 +11,7 @@ import ( func TracingSidecarMutation(enabled bool) deployment.Mutation { return deployment.Mutation{ Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), + Feature: feature.NewBooleanGate(enabled), Mutate: func(m *deployment.Mutator) error { m.EnsureContainer(corev1.Container{ Name: "jaeger-agent", diff --git a/examples/mutations-and-gating/resources/configmap.go b/examples/mutations-and-gating/resources/configmap.go index 270d55e9..51f333ff 100644 --- a/examples/mutations-and-gating/resources/configmap.go +++ b/examples/mutations-and-gating/resources/configmap.go @@ -31,7 +31,7 @@ func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap { // ConfigMap can be gated at the resource level by the controller. func NewConfigMapResource(owner *app.ExampleApp) (component.Resource, error) { builder := configmap.NewBuilder(BaseConfigMap(owner)) - builder.WithMutation(features.MetricsConfigMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + builder.WithMutation(features.MetricsConfigMutation(owner.Spec.EnableMetrics)) return builder.Build() } diff --git a/examples/mutations-and-gating/resources/configmap_test.go b/examples/mutations-and-gating/resources/configmap_test.go index d6c175b8..e067a74d 100644 --- a/examples/mutations-and-gating/resources/configmap_test.go +++ b/examples/mutations-and-gating/resources/configmap_test.go @@ -47,7 +47,7 @@ func TestConfigMapShape(t *testing.T) { owner := testOwner(tt.version) res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)). - WithMutation(features.MetricsConfigMutation(tt.version, tt.metrics)). + WithMutation(features.MetricsConfigMutation(tt.metrics)). Build() require.NoError(t, err) From 51b9ebcc9f91a6605424089b9eaabb93266137c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:20:21 +0200 Subject: [PATCH 12/37] docs: show a concrete version-gated example in the getting-started note The boolean-gating note pointed at version gating but showed no example in context. Add a version-gated gate snippet, note that the VersionConstraint is consumer-supplied, link the backward-compat mutation in the mutations-and-gating example, and target the Version-Gated Mutations section directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/getting-started.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 51ce9783..cf3d1638 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -217,12 +217,24 @@ func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) { } ``` -!!! note - - A non-empty version and a constraint slice turn the same gate into a version-gated mutation, which fires only when - the version satisfies the constraint. That is how backward compatibility patches are expressed without touching the - baseline. See [Primitives](primitives.md) for the mutation system and [Guidelines](guidelines.md) for the - baseline-as-latest pattern. +!!! note "Version-gated mutations" + + The same gate does version gating. Pass a non-empty version and a constraint slice instead of a boolean, and the + mutation fires only when the version satisfies the constraint. This is how backward-compatibility patches are + expressed without touching the baseline: + + ```go + Feature: feature.NewVersionGate(owner.Spec.Version, []feature.VersionConstraint{ + mustConstraint("< 2.0.0"), // a VersionConstraint you supply, e.g. backed by a semver library + }), + ``` + + The framework does not ship a `VersionConstraint` implementation, so you provide one (a few lines wrapping a semver + library). The + [`mutations-and-gating` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating) + wires a real one in its backward-compatibility mutation. See + [Version-Gated Mutations](primitives.md#version-gated-mutations) for the full pattern and + [Guidelines](guidelines.md) for the baseline-as-latest approach. ## Step 4: Compose the component From bb53f95f75069a66fb8dcf333a053fa7eccfb89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:46:50 +0200 Subject: [PATCH 13/37] example: demonstrate three-layer testing in mutations-and-gating Rework the example test suite to model the mutation/resource/component layers: - Mutation level: factor minimal baselines into a shared helpers_test.go; the per-mutation tests keep their explicit field assertions. - Resource level: drive the real NewDeploymentResource/NewConfigMapResource factory from the owner spec (instead of re-spelling the build inline), assert exactly which mutations fire via concepts.MutationInspector.FiringSet, and golden the rendered YAML. - Component level: extract BuildComponent (shared by the controller and the test) and golden the whole component with AssertComponentYAML. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/component_test.go | 73 +++++++++ .../mutations-and-gating/app/controller.go | 41 +++-- .../app/testdata/component-v1-minimal.yaml | 24 +++ .../app/testdata/component-v2-all.yaml | 54 +++++++ .../features/debug_logging_test.go | 17 +-- .../features/helpers_test.go | 53 +++++++ .../features/legacy_container_test.go | 23 +-- .../features/metrics_config_test.go | 10 +- .../features/tracing_sidecar_test.go | 17 +-- .../resources/configmap_test.go | 58 ++++---- .../resources/deployment_test.go | 140 +++++++++--------- 11 files changed, 340 insertions(+), 170 deletions(-) create mode 100644 examples/mutations-and-gating/app/component_test.go create mode 100644 examples/mutations-and-gating/app/testdata/component-v1-minimal.yaml create mode 100644 examples/mutations-and-gating/app/testdata/component-v2-all.yaml create mode 100644 examples/mutations-and-gating/features/helpers_test.go diff --git a/examples/mutations-and-gating/app/component_test.go b/examples/mutations-and-gating/app/component_test.go new file mode 100644 index 00000000..d24c4f31 --- /dev/null +++ b/examples/mutations-and-gating/app/component_test.go @@ -0,0 +1,73 @@ +package app_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/app" + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// update is this package's own -update flag. The resources package declares its +// own; the two live in separate test binaries, so there is no conflict. +var update = flag.Bool("update", false, "update golden files") + +func testOwner(spec sharedapp.ExampleAppSpec) *app.ExampleApp { + owner := &app.ExampleApp{Spec: spec} + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestBuildComponent is the component-level testing layer: it builds the whole +// component the controller reconciles (via the shared app.BuildComponent) and +// goldens the multi-document YAML of every resource it would apply. +// +// Unlike the resource-level tests, which pin one resource at a time, this asserts +// the composed desired state: the Deployment and ConfigMap rendered together, in +// the order the component applies them. It catches regressions in how the +// resources are wired into the component, not just in each resource's shape. +func TestBuildComponent(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + tests := []struct { + name string + spec sharedapp.ExampleAppSpec + golden string + }{ + { + name: "v2 all features on", + spec: sharedapp.ExampleAppSpec{ + Version: "2.0.0", + EnableDebugLogging: true, + EnableTracing: true, + EnableMetrics: true, + }, + golden: "testdata/component-v2-all.yaml", + }, + { + name: "v1 minimal", + spec: sharedapp.ExampleAppSpec{Version: "1.9.0"}, + golden: "testdata/component-v1-minimal.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := testOwner(tt.spec) + + comp, err := app.BuildComponent(owner, resources.NewDeploymentResource, resources.NewConfigMapResource) + require.NoError(t, err) + + golden.AssertComponentYAML(t, tt.golden, comp, golden.WithScheme(scheme), golden.Update(*update)) + }) + } +} diff --git a/examples/mutations-and-gating/app/controller.go b/examples/mutations-and-gating/app/controller.go index 09d09d44..7bbd6ed3 100644 --- a/examples/mutations-and-gating/app/controller.go +++ b/examples/mutations-and-gating/app/controller.go @@ -38,28 +38,47 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro } }() - deployResource, err := r.NewDeploymentResource(owner) + comp, err := BuildComponent(owner, r.NewDeploymentResource, r.NewConfigMapResource) if err != nil { return err } - cmResource, err := r.NewConfigMapResource(owner) + return comp.Reconcile(ctx, recCtx) +} + +// BuildComponent assembles the reconciled component for the given owner from the +// supplied resource factories. It is the single source of truth for how the +// Deployment and ConfigMap are composed into one component, shared by the +// controller's reconcile path and by component-level golden tests so both +// exercise the exact same assembly. +// +// The factories are passed in rather than imported so this package stays free of +// a dependency on the resources package (which already imports this one). The +// controller injects its production factories; tests pass the same ones via +// resources.NewDeploymentResource / resources.NewConfigMapResource. +// +// The ConfigMap is gated at the resource level: when metrics are disabled the +// framework deletes it. +func BuildComponent( + owner *ExampleApp, + newDeployment func(*ExampleApp) (component.Resource, error), + newConfigMap func(*ExampleApp) (component.Resource, error), +) (*component.Component, error) { + deployResource, err := newDeployment(owner) if err != nil { - return err + return nil, err + } + + cmResource, err := newConfigMap(owner) + if err != nil { + return nil, err } - comp, err := component.NewComponentBuilder(). + return component.NewComponentBuilder(). WithName("example-app"). WithConditionType("AppReady"). WithResource(deployResource). - // Gate the ConfigMap at the resource level: when metrics are disabled the - // framework deletes the ConfigMap. WithResource(cmResource, component.DeleteWhen(!owner.Spec.EnableMetrics)). Suspend(owner.Spec.Suspended). Build() - if err != nil { - return err - } - - return comp.Reconcile(ctx, recCtx) } diff --git a/examples/mutations-and-gating/app/testdata/component-v1-minimal.yaml b/examples/mutations-and-gating/app/testdata/component-v1-minimal.yaml new file mode 100644 index 00000000..0223991d --- /dev/null +++ b/examples/mutations-and-gating/app/testdata/component-v1-minimal.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - image: my-app:1.9.0 + name: server + ports: + - containerPort: 8080 + name: http + resources: {} diff --git a/examples/mutations-and-gating/app/testdata/component-v2-all.yaml b/examples/mutations-and-gating/app/testdata/component-v2-all.yaml new file mode 100644 index 00000000..aac67ecd --- /dev/null +++ b/examples/mutations-and-gating/app/testdata/component-v2-all.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: LOG_LEVEL + value: debug + - name: JAEGER_AGENT_HOST + value: localhost + image: my-app:2.0.0 + name: app + ports: + - containerPort: 8080 + name: http + - containerPort: 8081 + name: health + resources: {} + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: jaegertracing/jaeger-agent:1.28 + name: jaeger-agent + resources: {} +--- +apiVersion: v1 +data: + app.yaml: | + metrics: + enabled: true + path: /metrics + port: 9090 + server: + port: 8080 + timeout: 30s +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-config + namespace: default diff --git a/examples/mutations-and-gating/features/debug_logging_test.go b/examples/mutations-and-gating/features/debug_logging_test.go index 90f7877d..b7edb7d6 100644 --- a/examples/mutations-and-gating/features/debug_logging_test.go +++ b/examples/mutations-and-gating/features/debug_logging_test.go @@ -8,27 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // TestDebugLoggingMutation verifies that the mutation sets LOG_LEVEL=debug // on the application container. func TestDebugLoggingMutation(t *testing.T) { - base := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app"}, - }, - }, - }, - }, - } - - res, err := deployment.NewBuilder(base). + res, err := deployment.NewBuilder(baseDeployment()). WithMutation(features.DebugLoggingMutation(true)). Build() require.NoError(t, err) diff --git a/examples/mutations-and-gating/features/helpers_test.go b/examples/mutations-and-gating/features/helpers_test.go new file mode 100644 index 00000000..60365de3 --- /dev/null +++ b/examples/mutations-and-gating/features/helpers_test.go @@ -0,0 +1,53 @@ +package features_test + +import ( + "flag" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The mutation-level tests own no golden files, so -update is a no-op here. It is +// declared only so that running the whole example with `go test ./... -update` +// (to regenerate the resource and component goldens) does not fail flag parsing +// in this package's test binary. +var _ = flag.Bool("update", false, "no-op: this package has no golden files") + +// baseDeployment returns the minimal baseline Deployment the mutation-level tests +// apply a single mutation to. It carries one container named "app" with the v2 +// port layout (http + health), which is the smallest object the deployment +// mutations in this package operate on. Each mutation test starts from this base, +// applies exactly one mutation, previews, and asserts the specific field change. +func baseDeployment() *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Ports: []corev1.ContainerPort{ + {Name: "http", ContainerPort: 8080}, + {Name: "health", ContainerPort: 8081}, + }, + }, + }, + }, + }, + }, + } +} + +// baseConfigMap returns the minimal baseline ConfigMap the ConfigMap +// mutation-level tests apply a single mutation to. It carries the core server +// config in app.yaml, which the metrics mutation merges into. +func baseConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{ + "app.yaml": "server:\n port: 8080\n", + }, + } +} diff --git a/examples/mutations-and-gating/features/legacy_container_test.go b/examples/mutations-and-gating/features/legacy_container_test.go index 288af348..03e52c59 100644 --- a/examples/mutations-and-gating/features/legacy_container_test.go +++ b/examples/mutations-and-gating/features/legacy_container_test.go @@ -8,33 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // TestBackwardCompatV1Container verifies that the mutation renames the // container to "server" and drops the health port for pre-2.0 versions. func TestBackwardCompatV1Container(t *testing.T) { - base := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Ports: []corev1.ContainerPort{ - {Name: "http", ContainerPort: 8080}, - {Name: "health", ContainerPort: 8081}, - }, - }, - }, - }, - }, - }, - } - - res, err := deployment.NewBuilder(base). + res, err := deployment.NewBuilder(baseDeployment()). WithMutation(features.BackwardCompatV1Container("1.9.0")). Build() require.NoError(t, err) diff --git a/examples/mutations-and-gating/features/metrics_config_test.go b/examples/mutations-and-gating/features/metrics_config_test.go index 546f81c4..8c8db07d 100644 --- a/examples/mutations-and-gating/features/metrics_config_test.go +++ b/examples/mutations-and-gating/features/metrics_config_test.go @@ -8,20 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // TestMetricsConfigMutation verifies that the mutation merges a Prometheus // metrics section into app.yaml. func TestMetricsConfigMutation(t *testing.T) { - base := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - Data: map[string]string{ - "app.yaml": "server:\n port: 8080\n", - }, - } - - res, err := configmap.NewBuilder(base). + res, err := configmap.NewBuilder(baseConfigMap()). WithMutation(features.MetricsConfigMutation(true)). Build() require.NoError(t, err) diff --git a/examples/mutations-and-gating/features/tracing_sidecar_test.go b/examples/mutations-and-gating/features/tracing_sidecar_test.go index b4f46574..b34b37d3 100644 --- a/examples/mutations-and-gating/features/tracing_sidecar_test.go +++ b/examples/mutations-and-gating/features/tracing_sidecar_test.go @@ -8,27 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // TestTracingSidecarMutation verifies that the mutation injects a Jaeger // sidecar and sets JAEGER_AGENT_HOST on all containers. func TestTracingSidecarMutation(t *testing.T) { - base := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "web"}, - }, - }, - }, - }, - } - - res, err := deployment.NewBuilder(base). + res, err := deployment.NewBuilder(baseDeployment()). WithMutation(features.TracingSidecarMutation(true)). Build() require.NoError(t, err) diff --git a/examples/mutations-and-gating/resources/configmap_test.go b/examples/mutations-and-gating/resources/configmap_test.go index e067a74d..1e29228c 100644 --- a/examples/mutations-and-gating/resources/configmap_test.go +++ b/examples/mutations-and-gating/resources/configmap_test.go @@ -3,55 +3,63 @@ package resources_test import ( "testing" - "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" ) -// TestConfigMapShape verifies the ConfigMap's rendered YAML against golden -// files for each feature combination. +// TestConfigMapResource is the resource-level testing layer for the ConfigMap. +// Like the Deployment resource test, it drives the real NewConfigMapResource +// factory from the owner spec and asserts both the firing set (via +// concepts.MutationInspector) and the rendered golden YAML. // -// The baseline ConfigMap carries the core server config in its Data field. -// Boolean-gated mutations (MetricsConfigMutation) layer additional sections -// on top. Golden files pin the output so that changes to the baseline or -// mutation logic surface as test failures. -func TestConfigMapShape(t *testing.T) { +// The single registered mutation, "metrics-config", is boolean-gated on +// EnableMetrics, so it fires exactly when metrics are enabled. +func TestConfigMapResource(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) tests := []struct { - name string - version string - metrics bool - golden string + name string + spec sharedapp.ExampleAppSpec + wantFiring []string + goldenFile string }{ { - name: "baseline", - version: "1.0.0", - golden: "testdata/configmap-baseline.yaml", + name: "baseline", + spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + wantFiring: []string{}, + goldenFile: "testdata/configmap-baseline.yaml", }, { - name: "with metrics", - version: "1.0.0", - metrics: true, - golden: "testdata/configmap-metrics.yaml", + name: "with metrics", + spec: sharedapp.ExampleAppSpec{Version: "1.0.0", EnableMetrics: true}, + wantFiring: []string{"metrics-config"}, + goldenFile: "testdata/configmap-metrics.yaml", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - owner := testOwner(tt.version) + owner := testOwner(tt.spec) - res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)). - WithMutation(features.MetricsConfigMutation(tt.metrics)). - Build() + res, err := resources.NewConfigMapResource(owner) require.NoError(t, err) - golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update)) + inspector, ok := res.(concepts.MutationInspector) + require.True(t, ok, "resource must implement MutationInspector") + firing, err := inspector.FiringSet() + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantFiring, firing) + + previewer, ok := res.(golden.Previewer) + require.True(t, ok, "resource must implement golden.Previewer") + golden.AssertYAML(t, tt.goldenFile, previewer, golden.WithScheme(scheme), golden.Update(*update)) }) } } diff --git a/examples/mutations-and-gating/resources/deployment_test.go b/examples/mutations-and-gating/resources/deployment_test.go index 957eff99..e7b14e80 100644 --- a/examples/mutations-and-gating/resources/deployment_test.go +++ b/examples/mutations-and-gating/resources/deployment_test.go @@ -4,11 +4,11 @@ import ( "flag" "testing" - "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/runtime" @@ -16,110 +16,108 @@ import ( var update = flag.Bool("update", false, "update golden files") -func testOwner(version string) *sharedapp.ExampleApp { - owner := &sharedapp.ExampleApp{ - Spec: sharedapp.ExampleAppSpec{Version: version}, - } +func testOwner(spec sharedapp.ExampleAppSpec) *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{Spec: spec} owner.Name = "my-app" owner.Namespace = "default" return owner } -// TestDeploymentShape verifies the Deployment's rendered YAML against golden -// files for each supported version and feature combination. -// -// The baseline object (BaseDeployment) always reflects the latest version's -// desired state (v2: container "app", HTTP + health ports). Legacy mutations -// roll it back for older versions (v1: container "server", HTTP port only). +// TestDeploymentResource is the resource-level testing layer for the Deployment. +// It exercises the real NewDeploymentResource factory (not an inline rebuild) and +// asserts two things per case: // -// Mutation registration order mirrors NewDeploymentResource: DebugLogging -// targets ContainerNamed("app") and must come before LegacyContainer which -// renames the container. TracingSidecar uses AllContainers and is -// order-insensitive. +// 1. Introspection: exactly the expected set of mutations fires for the owner +// spec, via the concepts.MutationInspector the built resource implements. This +// pins the gating decisions independently of the rendered bytes, so a gate +// regression is reported as a firing-set mismatch rather than a golden diff. +// 2. Golden: the rendered YAML matches the snapshot for that version and feature +// combination, catching shape regressions (e.g. a baseline change that breaks +// the v1 backward-compat rollback). // -// These snapshots catch unintended regressions: if someone updates the -// baseline to accommodate a new v3 layout, the v1 and v2 golden files will -// fail unless the corresponding backward compat mutations still produce the -// correct shape. This ensures that changes to the latest version do not silently -// break the resource shape served to older versions. -func TestDeploymentShape(t *testing.T) { +// The owner spec drives the build: DebugLogging is boolean-gated on +// EnableDebugLogging, Tracing on EnableTracing, and BackwardCompatV1Container is +// version-gated to fire for versions < 2.0.0. +func TestDeploymentResource(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, appsv1.AddToScheme(scheme)) tests := []struct { - name string - version string - debug bool - tracing bool - golden string + name string + spec sharedapp.ExampleAppSpec + wantFiring []string + goldenFile string }{ - // v1 cases: BackwardCompatV1Container fires and rolls back the v2 - // baseline to the v1 container layout. If the baseline changes, - // these golden files catch any v1 regression. + // v1 cases: BackwardCompatV1Container fires (version < 2.0.0) and rolls the + // v2 baseline back to the v1 container layout. { - name: "v1 legacy container", - version: "1.9.0", - golden: "testdata/deployment-v1.yaml", + name: "v1 legacy container", + spec: sharedapp.ExampleAppSpec{Version: "1.9.0"}, + wantFiring: []string{"BackwardCompatV1Container"}, + goldenFile: "testdata/deployment-v1.yaml", }, { - name: "v1 with tracing", - version: "1.9.0", - tracing: true, - golden: "testdata/deployment-v1-tracing.yaml", + name: "v1 with tracing", + spec: sharedapp.ExampleAppSpec{Version: "1.9.0", EnableTracing: true}, + wantFiring: []string{"BackwardCompatV1Container", "Tracing"}, + goldenFile: "testdata/deployment-v1-tracing.yaml", }, { - name: "v1 with debug", - version: "1.9.0", - debug: true, - golden: "testdata/deployment-v1-debug.yaml", + name: "v1 with debug", + spec: sharedapp.ExampleAppSpec{Version: "1.9.0", EnableDebugLogging: true}, + wantFiring: []string{"BackwardCompatV1Container", "DebugLogging"}, + goldenFile: "testdata/deployment-v1-debug.yaml", }, { - name: "v1 with tracing and debug", - version: "1.9.0", - debug: true, - tracing: true, - golden: "testdata/deployment-v1-tracing-debug.yaml", + name: "v1 with tracing and debug", + spec: sharedapp.ExampleAppSpec{Version: "1.9.0", EnableDebugLogging: true, EnableTracing: true}, + wantFiring: []string{"BackwardCompatV1Container", "DebugLogging", "Tracing"}, + goldenFile: "testdata/deployment-v1-tracing-debug.yaml", }, - // v2 cases: no legacy mutation fires, so the baseline is rendered - // as-is. These golden files pin the current latest shape. + // v2 cases: BackwardCompatV1Container does not fire, so the baseline renders + // as the latest shape. { - name: "v2 baseline", - version: "2.0.0", - golden: "testdata/deployment-v2.yaml", + name: "v2 baseline", + spec: sharedapp.ExampleAppSpec{Version: "2.0.0"}, + wantFiring: []string{}, + goldenFile: "testdata/deployment-v2.yaml", }, { - name: "v2 with tracing", - version: "2.0.0", - tracing: true, - golden: "testdata/deployment-v2-tracing.yaml", + name: "v2 with tracing", + spec: sharedapp.ExampleAppSpec{Version: "2.0.0", EnableTracing: true}, + wantFiring: []string{"Tracing"}, + goldenFile: "testdata/deployment-v2-tracing.yaml", }, { - name: "v2 with debug", - version: "2.0.0", - debug: true, - golden: "testdata/deployment-v2-debug.yaml", + name: "v2 with debug", + spec: sharedapp.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}, + wantFiring: []string{"DebugLogging"}, + goldenFile: "testdata/deployment-v2-debug.yaml", }, { - name: "v2 with tracing and debug", - version: "2.0.0", - debug: true, - tracing: true, - golden: "testdata/deployment-v2-tracing-debug.yaml", + name: "v2 with tracing and debug", + spec: sharedapp.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true, EnableTracing: true}, + wantFiring: []string{"DebugLogging", "Tracing"}, + goldenFile: "testdata/deployment-v2-tracing-debug.yaml", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - owner := testOwner(tt.version) + owner := testOwner(tt.spec) + + res, err := resources.NewDeploymentResource(owner) + require.NoError(t, err) - res, err := deployment.NewBuilder(resources.BaseDeployment(owner)). - WithMutation(features.DebugLoggingMutation(tt.debug)). - WithMutation(features.BackwardCompatV1Container(tt.version)). - WithMutation(features.TracingSidecarMutation(tt.tracing)). - Build() + inspector, ok := res.(concepts.MutationInspector) + require.True(t, ok, "resource must implement MutationInspector") + firing, err := inspector.FiringSet() require.NoError(t, err) + assert.ElementsMatch(t, tt.wantFiring, firing) - golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update)) + previewer, ok := res.(golden.Previewer) + require.True(t, ok, "resource must implement golden.Previewer") + golden.AssertYAML(t, tt.goldenFile, previewer, golden.WithScheme(scheme), golden.Update(*update)) }) } } From caf718a892003e190aaab0fb66168cd1a0457b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:50:17 +0200 Subject: [PATCH 14/37] docs: rework getting-started Step 6 to use the real factory and introspection Build the resource through NewDeploymentResource (as the reconciler does) instead of re-spelling the build inline, assert which mutations fire via concepts.MutationInspector.FiringSet, then golden the output. Point to the Testing page for the full mutation/resource/component strategy. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/getting-started.md | 48 +++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index cf3d1638..8d285930 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -362,14 +362,15 @@ This entry point uses `ctrl "sigs.k8s.io/controller-runtime"` for `ctrl.Request` the owner's `Status.Conditions` carries one `AppReady` condition reflecting the aggregated health of the Deployment and ConfigMap. -## Step 6: Add a golden test +## Step 6: Test the resource -A golden test renders a resource to YAML and compares it against a checked-in snapshot. It catches unintended changes to -the rendered output, including changes a mutation makes for a given flag value. Use `golden.AssertYAML` for a single -resource, with a `-update` flag to regenerate the snapshot. +A resource test answers two questions: do the right mutations fire for a given spec, and does the rendered output match +what you expect? Build the resource through the same factory the reconciler uses, then assert both. -For typed Kubernetes objects, pass a scheme through `golden.WithScheme` so the serialized output carries `apiVersion` -and `kind`. +`res.FiringSet()`, from `concepts.MutationInspector` (which every built-in primitive implements), returns the names of +the mutations that fire for the resource's version and flags. A golden test then pins the rendered YAML against a +checked-in snapshot: `golden.AssertYAML` does the comparison, `golden.WithScheme` makes the output carry `apiVersion` +and `kind` for typed objects, and the `-update` flag regenerates the snapshot. ```go package resources_test @@ -378,20 +379,20 @@ import ( "flag" "testing" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/runtime" "your.module/app" - "your.module/features" "your.module/resources" ) var update = flag.Bool("update", false, "update golden files") -func TestDeploymentShape(t *testing.T) { +func TestDeploymentResource(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, appsv1.AddToScheme(scheme)) @@ -399,25 +400,36 @@ func TestDeploymentShape(t *testing.T) { owner.Name = "my-app" owner.Namespace = "default" - res, err := deployment.NewBuilder(resources.BaseDeployment(owner)). - WithMutation(features.DebugLoggingMutation(owner.Spec.EnableDebugLogging)). - Build() + // Build the resource exactly as the reconciler does. + res, err := resources.NewDeploymentResource(owner) + require.NoError(t, err) + + // Assert which mutations fire for this spec. Built-in primitives implement + // concepts.MutationInspector. + inspector, ok := res.(concepts.MutationInspector) + require.True(t, ok) + firing, err := inspector.FiringSet() require.NoError(t, err) + assert.ElementsMatch(t, []string{"DebugLogging"}, firing) - golden.AssertYAML(t, "testdata/deployment.yaml", res, golden.WithScheme(scheme), golden.Update(*update)) + // Pin the rendered output. The built resource implements golden.Previewer. + previewer, ok := res.(golden.Previewer) + require.True(t, ok) + golden.AssertYAML(t, "testdata/deployment.yaml", previewer, golden.WithScheme(scheme), golden.Update(*update)) } ``` Generate the snapshot, then run the test normally to verify it stays stable: ```bash -go test ./resources -run TestDeploymentShape -update -go test ./resources -run TestDeploymentShape +go test ./resources -run TestDeploymentResource -update +go test ./resources -run TestDeploymentResource ``` -Commit the generated `testdata/deployment.yaml` alongside the test. To assert the rendered output of an entire component -at once, use `golden.AssertComponentYAML`, which serializes every resource the component would apply into one -multi-document file. +Commit the generated `testdata/deployment.yaml` alongside the test. This resource test is one layer of a fuller +strategy: test each mutation against a baseline, assert which mutations fire at the resource level, and golden the whole +component with `golden.AssertComponentYAML`. See [Testing](testing.md) for all three layers and for version-matrix +generation across supported versions. ## Next steps From 3048b315812bfd3119d36455ac2a46439d03917f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:07:02 +0200 Subject: [PATCH 15/37] example: standardize mutations-and-gating resource and component tests on goldengen Replace the manual MutationInspector.FiringSet introspection in the resource tests with goldengen.Resource: declarative fixtures across versions assert which mutations fire (Requires/Forbids) and AssertComplete proves every registered mutation is covered. The component test moves to goldengen.Component the same way. Mutation-level unit tests are unchanged. Per-case golden files are replaced by regime-based goldengen output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/component_test.go | 121 ++++++++---- .../testdata/component/all/1.9.0.yaml} | 17 ++ .../all/2.0.0.yaml} | 0 .../app/testdata/component/manifest.yaml | 29 +++ .../minimal/1.9.0.yaml} | 0 .../testdata/component/minimal/2.0.0.yaml} | 0 .../resources/configmap_test.go | 90 ++++----- .../resources/deployment_test.go | 184 +++++++++--------- .../default/1.9.0.yaml} | 0 .../testdata/configmap/manifest.yaml | 16 ++ .../metrics/1.9.0.yaml} | 0 .../debug/1.9.0.yaml} | 0 .../debug/2.0.0.yaml} | 0 .../default/1.9.0.yaml} | 0 .../default/2.0.0.yaml} | 13 +- .../testdata/deployment/manifest.yaml | 38 ++++ .../tracing/1.9.0.yaml} | 0 .../tracing/2.0.0.yaml} | 0 18 files changed, 306 insertions(+), 202 deletions(-) rename examples/mutations-and-gating/{resources/testdata/deployment-v1-tracing-debug.yaml => app/testdata/component/all/1.9.0.yaml} (73%) rename examples/mutations-and-gating/app/testdata/{component-v2-all.yaml => component/all/2.0.0.yaml} (100%) create mode 100644 examples/mutations-and-gating/app/testdata/component/manifest.yaml rename examples/mutations-and-gating/app/testdata/{component-v1-minimal.yaml => component/minimal/1.9.0.yaml} (100%) rename examples/mutations-and-gating/{resources/testdata/deployment-v2.yaml => app/testdata/component/minimal/2.0.0.yaml} (100%) rename examples/mutations-and-gating/resources/testdata/{configmap-baseline.yaml => configmap/default/1.9.0.yaml} (100%) create mode 100644 examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml rename examples/mutations-and-gating/resources/testdata/{configmap-metrics.yaml => configmap/metrics/1.9.0.yaml} (100%) rename examples/mutations-and-gating/resources/testdata/{deployment-v1-debug.yaml => deployment/debug/1.9.0.yaml} (100%) rename examples/mutations-and-gating/resources/testdata/{deployment-v2-debug.yaml => deployment/debug/2.0.0.yaml} (100%) rename examples/mutations-and-gating/resources/testdata/{deployment-v1.yaml => deployment/default/1.9.0.yaml} (100%) rename examples/mutations-and-gating/resources/testdata/{deployment-v2-tracing-debug.yaml => deployment/default/2.0.0.yaml} (57%) create mode 100644 examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml rename examples/mutations-and-gating/resources/testdata/{deployment-v1-tracing.yaml => deployment/tracing/1.9.0.yaml} (100%) rename examples/mutations-and-gating/resources/testdata/{deployment-v2-tracing.yaml => deployment/tracing/2.0.0.yaml} (100%) diff --git a/examples/mutations-and-gating/app/component_test.go b/examples/mutations-and-gating/app/component_test.go index d24c4f31..289db6af 100644 --- a/examples/mutations-and-gating/app/component_test.go +++ b/examples/mutations-and-gating/app/component_test.go @@ -2,72 +2,107 @@ package app_test import ( "flag" + "os" "testing" "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/app" "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" - "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" ) // update is this package's own -update flag. The resources package declares its // own; the two live in separate test binaries, so there is no conflict. var update = flag.Bool("update", false, "update golden files") -func testOwner(spec sharedapp.ExampleAppSpec) *app.ExampleApp { - owner := &app.ExampleApp{Spec: spec} - owner.Name = "my-app" - owner.Namespace = "default" - return owner +// scheme resolves TypeMeta for the rendered resources in the component stream. +var scheme = newScheme() + +// newScheme returns a scheme with the core and apps Kubernetes types registered. +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + panic(err) + } + return s } -// TestBuildComponent is the component-level testing layer: it builds the whole -// component the controller reconciles (via the shared app.BuildComponent) and -// goldens the multi-document YAML of every resource it would apply. -// -// Unlike the resource-level tests, which pin one resource at a time, this asserts -// the composed desired state: the Deployment and ConfigMap rendered together, in -// the order the component applies them. It catches regressions in how the -// resources are wired into the component, not just in each resource's shape. -func TestBuildComponent(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, appsv1.AddToScheme(scheme)) - require.NoError(t, corev1.AddToScheme(scheme)) +// owner returns a fixture owner. The Build function overwrites Spec.Version per +// version, so the Version set on a fixture spec is just a placeholder. +func owner(spec sharedapp.ExampleAppSpec) *app.ExampleApp { + o := &app.ExampleApp{Spec: spec} + o.Name = "my-app" + o.Namespace = "default" + return o +} - tests := []struct { - name string - spec sharedapp.ExampleAppSpec - golden string - }{ +// gen declares the component matrix. It builds the whole component the controller +// reconciles (via the shared app.BuildComponent) and goldens the multi-document +// YAML of every resource it would apply. +// +// The component aggregates the registered mutations of both resources, so its four +// registered mutations are BackwardCompatV1Container, DebugLogging, and Tracing from +// the Deployment plus metrics-config from the ConfigMap. The "all" fixture turns +// every flag on at a pre-2.0.0 version so all four fire and are accounted for; the +// "minimal" fixture turns them off at 2.0.0 and forbids them, pinning the boundary +// from the other side. +var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ + Dir: "testdata/component", + Versions: []string{"1.9.0", "2.0.0"}, + Fixtures: []goldengen.Fixture[*app.ExampleApp]{ { - name: "v2 all features on", - spec: sharedapp.ExampleAppSpec{ - Version: "2.0.0", + Name: "all", + Spec: owner(sharedapp.ExampleAppSpec{ EnableDebugLogging: true, EnableTracing: true, EnableMetrics: true, + }), + Requires: []goldengen.Expect{ + {Name: "DebugLogging"}, + {Name: "Tracing"}, + {Name: "metrics-config"}, + {Name: "BackwardCompatV1Container", For: "1.9.0"}, // legacy container before 2.0.0 + }, + Forbids: []goldengen.Expect{ + {Name: "BackwardCompatV1Container", For: "2.0.0"}, // not from 2.0.0 onward }, - golden: "testdata/component-v2-all.yaml", }, { - name: "v1 minimal", - spec: sharedapp.ExampleAppSpec{Version: "1.9.0"}, - golden: "testdata/component-v1-minimal.yaml", + Name: "minimal", + Spec: owner(sharedapp.ExampleAppSpec{}), + Forbids: []goldengen.Expect{ + {Name: "DebugLogging"}, + {Name: "Tracing"}, + {Name: "metrics-config"}, + {Name: "BackwardCompatV1Container", For: "2.0.0"}, + }, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - owner := testOwner(tt.spec) + }, + Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) { + o := spec.DeepCopyObject().(*app.ExampleApp) + o.Spec.Version = version + comp, err := app.BuildComponent(o, resources.NewDeploymentResource, resources.NewConfigMapResource) + if err != nil { + return nil, err + } + return goldengen.Component(comp, scheme), nil + }, +}) - comp, err := app.BuildComponent(owner, resources.NewDeploymentResource, resources.NewConfigMapResource) - require.NoError(t, err) +// TestBuildComponentVersionMatrix runs the component sweep: it asserts the gating +// expectations across the composed desired state and writes or compares one golden +// per regime plus the coverage manifest. Unlike the resource-level matrices, which +// pin one resource at a time, this goldens the Deployment and ConfigMap rendered +// together, in the order the component applies them. +func TestBuildComponentVersionMatrix(t *testing.T) { + gen.WithUpdate(*update) + gen.Run(t) +} - golden.AssertComponentYAML(t, tt.golden, comp, golden.WithScheme(scheme), golden.Update(*update)) - }) - } +// TestMain runs the package tests, then proves every registered mutation across the +// composed component is required or excluded before reporting the exit code. +func TestMain(m *testing.M) { + os.Exit(gen.AssertComplete(m.Run())) } diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml b/examples/mutations-and-gating/app/testdata/component/all/1.9.0.yaml similarity index 73% rename from examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml rename to examples/mutations-and-gating/app/testdata/component/all/1.9.0.yaml index 557acf1d..20a90330 100644 --- a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml +++ b/examples/mutations-and-gating/app/testdata/component/all/1.9.0.yaml @@ -33,3 +33,20 @@ spec: image: jaegertracing/jaeger-agent:1.28 name: jaeger-agent resources: {} +--- +apiVersion: v1 +data: + app.yaml: | + metrics: + enabled: true + path: /metrics + port: 9090 + server: + port: 8080 + timeout: 30s +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-config + namespace: default diff --git a/examples/mutations-and-gating/app/testdata/component-v2-all.yaml b/examples/mutations-and-gating/app/testdata/component/all/2.0.0.yaml similarity index 100% rename from examples/mutations-and-gating/app/testdata/component-v2-all.yaml rename to examples/mutations-and-gating/app/testdata/component/all/2.0.0.yaml diff --git a/examples/mutations-and-gating/app/testdata/component/manifest.yaml b/examples/mutations-and-gating/app/testdata/component/manifest.yaml new file mode 100644 index 00000000..24074e6d --- /dev/null +++ b/examples/mutations-and-gating/app/testdata/component/manifest.yaml @@ -0,0 +1,29 @@ +fixtures: +- name: all + regimes: + - firing: + - BackwardCompatV1Container + - DebugLogging + - Tracing + - metrics-config + representative: 1.9.0 + versions: + - 1.9.0 + - firing: + - DebugLogging + - Tracing + - metrics-config + representative: 2.0.0 + versions: + - 2.0.0 +- name: minimal + regimes: + - firing: + - BackwardCompatV1Container + representative: 1.9.0 + versions: + - 1.9.0 + - firing: null + representative: 2.0.0 + versions: + - 2.0.0 diff --git a/examples/mutations-and-gating/app/testdata/component-v1-minimal.yaml b/examples/mutations-and-gating/app/testdata/component/minimal/1.9.0.yaml similarity index 100% rename from examples/mutations-and-gating/app/testdata/component-v1-minimal.yaml rename to examples/mutations-and-gating/app/testdata/component/minimal/1.9.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2.yaml b/examples/mutations-and-gating/app/testdata/component/minimal/2.0.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/deployment-v2.yaml rename to examples/mutations-and-gating/app/testdata/component/minimal/2.0.0.yaml diff --git a/examples/mutations-and-gating/resources/configmap_test.go b/examples/mutations-and-gating/resources/configmap_test.go index 1e29228c..705fb8d5 100644 --- a/examples/mutations-and-gating/resources/configmap_test.go +++ b/examples/mutations-and-gating/resources/configmap_test.go @@ -5,61 +5,51 @@ import ( "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" ) -// TestConfigMapResource is the resource-level testing layer for the ConfigMap. -// Like the Deployment resource test, it drives the real NewConfigMapResource -// factory from the owner spec and asserts both the firing set (via -// concepts.MutationInspector) and the rendered golden YAML. +// configMapGen declares the ConfigMap resource matrix. The ConfigMap registers one +// mutation, "metrics-config", boolean-gated on EnableMetrics. The default fixture +// pins that it does not fire with metrics off; the metrics fixture pins that it +// fires with metrics on, which is what accounts for the mutation in AssertComplete. // -// The single registered mutation, "metrics-config", is boolean-gated on -// EnableMetrics, so it fires exactly when metrics are enabled. -func TestConfigMapResource(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, corev1.AddToScheme(scheme)) - - tests := []struct { - name string - spec sharedapp.ExampleAppSpec - wantFiring []string - goldenFile string - }{ +// The version sweep is the same universe as the Deployment, but no ConfigMap +// mutation is version-gated, so both versions collapse to a single regime per +// fixture and only one golden is written per fixture. +var configMapGen = goldengen.New(goldengen.Config[*sharedapp.ExampleApp]{ + Dir: "testdata/configmap", + Versions: []string{"1.9.0", "2.0.0"}, + Fixtures: []goldengen.Fixture[*sharedapp.ExampleApp]{ { - name: "baseline", - spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, - wantFiring: []string{}, - goldenFile: "testdata/configmap-baseline.yaml", + Name: "default", + Spec: owner(sharedapp.ExampleAppSpec{}), + Forbids: []goldengen.Expect{ + {Name: "metrics-config"}, // metrics off, so it never fires + }, }, { - name: "with metrics", - spec: sharedapp.ExampleAppSpec{Version: "1.0.0", EnableMetrics: true}, - wantFiring: []string{"metrics-config"}, - goldenFile: "testdata/configmap-metrics.yaml", + Name: "metrics", + Spec: owner(sharedapp.ExampleAppSpec{EnableMetrics: true}), + Requires: []goldengen.Expect{ + {Name: "metrics-config"}, // boolean-gated on EnableMetrics + }, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - owner := testOwner(tt.spec) - - res, err := resources.NewConfigMapResource(owner) - require.NoError(t, err) - - inspector, ok := res.(concepts.MutationInspector) - require.True(t, ok, "resource must implement MutationInspector") - firing, err := inspector.FiringSet() - require.NoError(t, err) - assert.ElementsMatch(t, tt.wantFiring, firing) - - previewer, ok := res.(golden.Previewer) - require.True(t, ok, "resource must implement golden.Previewer") - golden.AssertYAML(t, tt.goldenFile, previewer, golden.WithScheme(scheme), golden.Update(*update)) - }) - } + }, + Build: func(version string, spec *sharedapp.ExampleApp) (goldengen.Unit, error) { + o := spec.DeepCopyObject().(*sharedapp.ExampleApp) + o.Spec.Version = version + res, err := resources.NewConfigMapResource(o) + if err != nil { + return nil, err + } + return goldengen.Resource(res.(goldengen.ResourcePreviewer), scheme), nil + }, +}) + +// TestConfigMapVersionMatrix runs the ConfigMap sweep: it asserts the gating +// expectations and writes or compares one golden per regime plus the coverage +// manifest. +func TestConfigMapVersionMatrix(t *testing.T) { + configMapGen.WithUpdate(*update) + configMapGen.Run(t) } diff --git a/examples/mutations-and-gating/resources/deployment_test.go b/examples/mutations-and-gating/resources/deployment_test.go index e7b14e80..278f6ee9 100644 --- a/examples/mutations-and-gating/resources/deployment_test.go +++ b/examples/mutations-and-gating/resources/deployment_test.go @@ -2,122 +2,112 @@ package resources_test import ( "flag" + "os" "testing" "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" ) +// update is wired to the -update flag so both resource generators overwrite their +// goldens and manifests when set: +// +// go test ./examples/mutations-and-gating/resources/ -update +// +// It is declared once for the whole resources test package; the Deployment and +// ConfigMap generators share it. var update = flag.Bool("update", false, "update golden files") -func testOwner(spec sharedapp.ExampleAppSpec) *sharedapp.ExampleApp { - owner := &sharedapp.ExampleApp{Spec: spec} - owner.Name = "my-app" - owner.Namespace = "default" - return owner +// scheme resolves TypeMeta for the rendered resources. +var scheme = newScheme() + +// newScheme returns a scheme with the core and apps Kubernetes types registered. +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + panic(err) + } + return s +} + +// owner returns a fixture owner. The Build functions overwrite Spec.Version per +// version, so the Version set on a fixture spec is just a placeholder. +func owner(spec sharedapp.ExampleAppSpec) *sharedapp.ExampleApp { + o := &sharedapp.ExampleApp{Spec: spec} + o.Name = "my-app" + o.Namespace = "default" + return o } -// TestDeploymentResource is the resource-level testing layer for the Deployment. -// It exercises the real NewDeploymentResource factory (not an inline rebuild) and -// asserts two things per case: +// deploymentGen declares the Deployment resource matrix. The Deployment registers +// three mutations: // -// 1. Introspection: exactly the expected set of mutations fires for the owner -// spec, via the concepts.MutationInspector the built resource implements. This -// pins the gating decisions independently of the rendered bytes, so a gate -// regression is reported as a firing-set mismatch rather than a golden diff. -// 2. Golden: the rendered YAML matches the snapshot for that version and feature -// combination, catching shape regressions (e.g. a baseline change that breaks -// the v1 backward-compat rollback). +// - BackwardCompatV1Container is version-gated to fire for versions < 2.0.0, so the +// version sweep splits the default fixture into a pre-2.0.0 regime (the mutation +// fires) and a 2.0.0 regime (it does not). +// - DebugLogging is boolean-gated on EnableDebugLogging. +// - Tracing is boolean-gated on EnableTracing. // -// The owner spec drives the build: DebugLogging is boolean-gated on -// EnableDebugLogging, Tracing on EnableTracing, and BackwardCompatV1Container is -// version-gated to fire for versions < 2.0.0. -func TestDeploymentResource(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, appsv1.AddToScheme(scheme)) - - tests := []struct { - name string - spec sharedapp.ExampleAppSpec - wantFiring []string - goldenFile string - }{ - // v1 cases: BackwardCompatV1Container fires (version < 2.0.0) and rolls the - // v2 baseline back to the v1 container layout. - { - name: "v1 legacy container", - spec: sharedapp.ExampleAppSpec{Version: "1.9.0"}, - wantFiring: []string{"BackwardCompatV1Container"}, - goldenFile: "testdata/deployment-v1.yaml", - }, - { - name: "v1 with tracing", - spec: sharedapp.ExampleAppSpec{Version: "1.9.0", EnableTracing: true}, - wantFiring: []string{"BackwardCompatV1Container", "Tracing"}, - goldenFile: "testdata/deployment-v1-tracing.yaml", - }, +// One fixture per boolean flag exercises the gate, and the version sweep covers the +// version gate. Together the fixtures' Requires account for all three mutations. +var deploymentGen = goldengen.New(goldengen.Config[*sharedapp.ExampleApp]{ + Dir: "testdata/deployment", + Versions: []string{"1.9.0", "2.0.0"}, + Fixtures: []goldengen.Fixture[*sharedapp.ExampleApp]{ { - name: "v1 with debug", - spec: sharedapp.ExampleAppSpec{Version: "1.9.0", EnableDebugLogging: true}, - wantFiring: []string{"BackwardCompatV1Container", "DebugLogging"}, - goldenFile: "testdata/deployment-v1-debug.yaml", + Name: "default", + Spec: owner(sharedapp.ExampleAppSpec{}), + Requires: []goldengen.Expect{ + {Name: "BackwardCompatV1Container", For: "1.9.0"}, // legacy container before 2.0.0 + }, + Forbids: []goldengen.Expect{ + {Name: "BackwardCompatV1Container", For: "2.0.0"}, // not from 2.0.0 onward + }, }, { - name: "v1 with tracing and debug", - spec: sharedapp.ExampleAppSpec{Version: "1.9.0", EnableDebugLogging: true, EnableTracing: true}, - wantFiring: []string{"BackwardCompatV1Container", "DebugLogging", "Tracing"}, - goldenFile: "testdata/deployment-v1-tracing-debug.yaml", + Name: "debug", + Spec: owner(sharedapp.ExampleAppSpec{EnableDebugLogging: true}), + Requires: []goldengen.Expect{ + {Name: "DebugLogging"}, // boolean-gated on EnableDebugLogging + }, }, - // v2 cases: BackwardCompatV1Container does not fire, so the baseline renders - // as the latest shape. { - name: "v2 baseline", - spec: sharedapp.ExampleAppSpec{Version: "2.0.0"}, - wantFiring: []string{}, - goldenFile: "testdata/deployment-v2.yaml", + Name: "tracing", + Spec: owner(sharedapp.ExampleAppSpec{EnableTracing: true}), + Requires: []goldengen.Expect{ + {Name: "Tracing"}, // boolean-gated on EnableTracing + }, }, - { - name: "v2 with tracing", - spec: sharedapp.ExampleAppSpec{Version: "2.0.0", EnableTracing: true}, - wantFiring: []string{"Tracing"}, - goldenFile: "testdata/deployment-v2-tracing.yaml", - }, - { - name: "v2 with debug", - spec: sharedapp.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}, - wantFiring: []string{"DebugLogging"}, - goldenFile: "testdata/deployment-v2-debug.yaml", - }, - { - name: "v2 with tracing and debug", - spec: sharedapp.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true, EnableTracing: true}, - wantFiring: []string{"DebugLogging", "Tracing"}, - goldenFile: "testdata/deployment-v2-tracing-debug.yaml", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - owner := testOwner(tt.spec) - - res, err := resources.NewDeploymentResource(owner) - require.NoError(t, err) + }, + Build: func(version string, spec *sharedapp.ExampleApp) (goldengen.Unit, error) { + o := spec.DeepCopyObject().(*sharedapp.ExampleApp) + o.Spec.Version = version + res, err := resources.NewDeploymentResource(o) + if err != nil { + return nil, err + } + return goldengen.Resource(res.(goldengen.ResourcePreviewer), scheme), nil + }, +}) - inspector, ok := res.(concepts.MutationInspector) - require.True(t, ok, "resource must implement MutationInspector") - firing, err := inspector.FiringSet() - require.NoError(t, err) - assert.ElementsMatch(t, tt.wantFiring, firing) +// TestDeploymentVersionMatrix runs the Deployment sweep: it asserts the gating +// expectations and writes or compares one golden per regime plus the coverage +// manifest. +func TestDeploymentVersionMatrix(t *testing.T) { + deploymentGen.WithUpdate(*update) + deploymentGen.Run(t) +} - previewer, ok := res.(golden.Previewer) - require.True(t, ok, "resource must implement golden.Previewer") - golden.AssertYAML(t, tt.goldenFile, previewer, golden.WithScheme(scheme), golden.Update(*update)) - }) - } +// TestMain runs the package tests, then proves every registered mutation across +// both resource generators is required or excluded before reporting the exit code. +// Chaining the AssertComplete calls means the package fails if either resource +// leaves a mutation unaccounted. +func TestMain(m *testing.M) { + code := m.Run() + code = deploymentGen.AssertComplete(code) + code = configMapGen.AssertComplete(code) + os.Exit(code) } diff --git a/examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml b/examples/mutations-and-gating/resources/testdata/configmap/default/1.9.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml rename to examples/mutations-and-gating/resources/testdata/configmap/default/1.9.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml b/examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml new file mode 100644 index 00000000..d4f10458 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml @@ -0,0 +1,16 @@ +fixtures: +- name: default + regimes: + - firing: null + representative: 1.9.0 + versions: + - 1.9.0 + - 2.0.0 +- name: metrics + regimes: + - firing: + - metrics-config + representative: 1.9.0 + versions: + - 1.9.0 + - 2.0.0 diff --git a/examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml b/examples/mutations-and-gating/resources/testdata/configmap/metrics/1.9.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml rename to examples/mutations-and-gating/resources/testdata/configmap/metrics/1.9.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment/debug/1.9.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml rename to examples/mutations-and-gating/resources/testdata/deployment/debug/1.9.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment/debug/2.0.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml rename to examples/mutations-and-gating/resources/testdata/deployment/debug/2.0.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1.yaml b/examples/mutations-and-gating/resources/testdata/deployment/default/1.9.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/deployment-v1.yaml rename to examples/mutations-and-gating/resources/testdata/deployment/default/1.9.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment/default/2.0.0.yaml similarity index 57% rename from examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml rename to examples/mutations-and-gating/resources/testdata/deployment/default/2.0.0.yaml index 31da05b2..712145c4 100644 --- a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml +++ b/examples/mutations-and-gating/resources/testdata/deployment/default/2.0.0.yaml @@ -16,12 +16,7 @@ spec: app: my-app spec: containers: - - env: - - name: LOG_LEVEL - value: debug - - name: JAEGER_AGENT_HOST - value: localhost - image: my-app:2.0.0 + - image: my-app:2.0.0 name: app ports: - containerPort: 8080 @@ -29,9 +24,3 @@ spec: - containerPort: 8081 name: health resources: {} - - env: - - name: JAEGER_AGENT_HOST - value: localhost - image: jaegertracing/jaeger-agent:1.28 - name: jaeger-agent - resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml b/examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml new file mode 100644 index 00000000..90a71490 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml @@ -0,0 +1,38 @@ +fixtures: +- name: default + regimes: + - firing: + - BackwardCompatV1Container + representative: 1.9.0 + versions: + - 1.9.0 + - firing: null + representative: 2.0.0 + versions: + - 2.0.0 +- name: debug + regimes: + - firing: + - BackwardCompatV1Container + - DebugLogging + representative: 1.9.0 + versions: + - 1.9.0 + - firing: + - DebugLogging + representative: 2.0.0 + versions: + - 2.0.0 +- name: tracing + regimes: + - firing: + - BackwardCompatV1Container + - Tracing + representative: 1.9.0 + versions: + - 1.9.0 + - firing: + - Tracing + representative: 2.0.0 + versions: + - 2.0.0 diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml b/examples/mutations-and-gating/resources/testdata/deployment/tracing/1.9.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml rename to examples/mutations-and-gating/resources/testdata/deployment/tracing/1.9.0.yaml diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml b/examples/mutations-and-gating/resources/testdata/deployment/tracing/2.0.0.yaml similarity index 100% rename from examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml rename to examples/mutations-and-gating/resources/testdata/deployment/tracing/2.0.0.yaml From 9c79549dc2048e814ce635eb94473570ae8b6db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:14:32 +0200 Subject: [PATCH 16/37] example: make resource and component tests consistent across examples Build resources through their real factory functions instead of re-spelling the build inline, and assert the firing set via concepts.MutationInspector where a resource registers mutations (component-prerequisites, custom-resource). Extract BuildComponent helpers shared by the controller and a new component-level test that goldens each example's distinctive component behavior (prerequisite ordering, extraction feeding a guard, grace suppression). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/component_test.go | 71 +++++++++++++++++++ .../component-prerequisites/app/controller.go | 47 +++++++----- .../app/testdata/app-component.yaml | 21 ++++++ .../app/testdata/infra-component.yaml | 9 +++ .../resources/configmap_test.go | 10 +-- .../resources/deployment_test.go | 50 +++++++------ .../resources/certificate_test.go | 58 +++++++-------- .../app/component_test.go | 59 +++++++++++++++ .../extraction-and-guards/app/controller.go | 25 ++++--- .../app/testdata/component.yaml | 21 ++++++ .../resources/configmap_test.go | 13 ++-- .../resources/secret_test.go | 13 ++-- .../grace-inconsistency/app/component_test.go | 58 +++++++++++++++ .../grace-inconsistency/app/controller.go | 24 +++++-- .../app/testdata/component.yaml | 23 ++++++ .../resources/deployment_test.go | 11 +-- 16 files changed, 403 insertions(+), 110 deletions(-) create mode 100644 examples/component-prerequisites/app/component_test.go create mode 100644 examples/component-prerequisites/app/testdata/app-component.yaml create mode 100644 examples/component-prerequisites/app/testdata/infra-component.yaml create mode 100644 examples/extraction-and-guards/app/component_test.go create mode 100644 examples/extraction-and-guards/app/testdata/component.yaml create mode 100644 examples/grace-inconsistency/app/component_test.go create mode 100644 examples/grace-inconsistency/app/testdata/component.yaml diff --git a/examples/component-prerequisites/app/component_test.go b/examples/component-prerequisites/app/component_test.go new file mode 100644 index 00000000..7ffe2f63 --- /dev/null +++ b/examples/component-prerequisites/app/component_test.go @@ -0,0 +1,71 @@ +package app_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/app" + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +// update is this package's own -update flag. The resources package declares its +// own; the two live in separate test binaries, so there is no conflict. +var update = flag.Bool("update", false, "update golden files") + +// scheme resolves TypeMeta for the rendered resources in the component stream. +var scheme = newScheme() + +// newScheme returns a scheme with the core and apps Kubernetes types registered. +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + panic(err) + } + return s +} + +func testOwner() *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +func testController() *app.Controller { + return &app.Controller{ + NewConfigMapResource: resources.NewConfigMapResource, + NewDeploymentResource: resources.NewDeploymentResource, + } +} + +// TestBuildInfraComponent goldens the infra component the controller reconciles +// first: a single ConfigMap with no prerequisites. The controller and this test +// build the component the same way, so the reconciled component and the snapshot +// stay in lockstep. +func TestBuildInfraComponent(t *testing.T) { + comp, err := testController().BuildInfraComponent(testOwner()) + require.NoError(t, err) + + golden.AssertComponentYAML(t, "testdata/infra-component.yaml", comp, + golden.WithScheme(scheme), golden.Update(*update)) +} + +// TestBuildAppComponent goldens the app component the controller reconciles +// second: a Deployment gated behind the InfraReady prerequisite. The DependsOn +// ordering is the point of this example. The controller and this test build the +// component the same way, so the reconciled component and the snapshot stay in +// lockstep. +func TestBuildAppComponent(t *testing.T) { + comp, err := testController().BuildAppComponent(testOwner()) + require.NoError(t, err) + + golden.AssertComponentYAML(t, "testdata/app-component.yaml", comp, + golden.WithScheme(scheme), golden.Update(*update)) +} diff --git a/examples/component-prerequisites/app/controller.go b/examples/component-prerequisites/app/controller.go index f760a41e..96486101 100644 --- a/examples/component-prerequisites/app/controller.go +++ b/examples/component-prerequisites/app/controller.go @@ -48,40 +48,55 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro }() // --- Infra component: no prerequisites --- - cmResource, err := r.NewConfigMapResource(owner) + infra, err := r.BuildInfraComponent(owner) if err != nil { return err } - infra, err := component.NewComponentBuilder(). - WithName("infra"). - WithConditionType("InfraReady"). - WithResource(cmResource). - Build() - if err != nil { + if err := infra.Reconcile(ctx, recCtx); err != nil { return err } - if err := infra.Reconcile(ctx, recCtx); err != nil { + // --- App component: depends on InfraReady --- + app, err := r.BuildAppComponent(owner) + if err != nil { return err } - // --- App component: depends on InfraReady --- + return app.Reconcile(ctx, recCtx) +} + +// BuildInfraComponent assembles the infra component: a single ConfigMap reporting +// the InfraReady condition, with no prerequisites. The controller and tests share +// this so the reconciled component and the golden snapshot stay in lockstep. +func (r *Controller) BuildInfraComponent(owner *ExampleApp) (*component.Component, error) { + cmResource, err := r.NewConfigMapResource(owner) + if err != nil { + return nil, err + } + + return component.NewComponentBuilder(). + WithName("infra"). + WithConditionType("InfraReady"). + WithResource(cmResource). + Build() +} + +// BuildAppComponent assembles the app component: a Deployment reporting the +// AppReady condition, gated behind the InfraReady prerequisite. The DependsOn +// prerequisite is the point of this example, so the controller and tests build +// the component the same way. +func (r *Controller) BuildAppComponent(owner *ExampleApp) (*component.Component, error) { deployResource, err := r.NewDeploymentResource(owner) if err != nil { - return err + return nil, err } - app, err := component.NewComponentBuilder(). + return component.NewComponentBuilder(). WithName("app"). WithConditionType("AppReady"). WithResource(deployResource). WithPrerequisite(component.DependsOn("InfraReady")). Suspend(owner.Spec.Suspended). Build() - if err != nil { - return err - } - - return app.Reconcile(ctx, recCtx) } diff --git a/examples/component-prerequisites/app/testdata/app-component.yaml b/examples/component-prerequisites/app/testdata/app-component.yaml new file mode 100644 index 00000000..fa6cf1c8 --- /dev/null +++ b/examples/component-prerequisites/app/testdata/app-component.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - image: my-app:1.0.0 + name: app + resources: {} diff --git a/examples/component-prerequisites/app/testdata/infra-component.yaml b/examples/component-prerequisites/app/testdata/infra-component.yaml new file mode 100644 index 00000000..3c257dea --- /dev/null +++ b/examples/component-prerequisites/app/testdata/infra-component.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +data: + cluster-dns: 10.96.0.10 +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-infra-config + namespace: default diff --git a/examples/component-prerequisites/resources/configmap_test.go b/examples/component-prerequisites/resources/configmap_test.go index 8c77efa0..84c99744 100644 --- a/examples/component-prerequisites/resources/configmap_test.go +++ b/examples/component-prerequisites/resources/configmap_test.go @@ -6,7 +6,6 @@ import ( "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -24,16 +23,17 @@ func testOwner(version string) *sharedapp.ExampleApp { return owner } -// TestConfigMapShape pins the infra component's ConfigMap baseline. Changes -// to the base object surface as a diff against the golden file. +// TestConfigMapShape pins the infra component's ConfigMap as built by its +// factory. The factory registers no mutations, so the golden file captures the +// full desired state. Changes to the base object surface as a diff. func TestConfigMapShape(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) owner := testOwner("1.0.0") - res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build() + res, err := resources.NewConfigMapResource(owner) require.NoError(t, err) - golden.AssertYAML(t, "testdata/configmap.yaml", res, + golden.AssertYAML(t, "testdata/configmap.yaml", res.(golden.Previewer), golden.WithScheme(scheme), golden.Update(*update)) } diff --git a/examples/component-prerequisites/resources/deployment_test.go b/examples/component-prerequisites/resources/deployment_test.go index 35822ba4..396ddea5 100644 --- a/examples/component-prerequisites/resources/deployment_test.go +++ b/examples/component-prerequisites/resources/deployment_test.go @@ -1,24 +1,39 @@ package resources_test import ( - "fmt" "testing" "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources" - "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" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/runtime" ) -// TestDeploymentShape verifies the app component's Deployment for each version. -// The baseline carries the latest container layout; the version mutation sets -// the image tag. Golden files for each version catch regressions when the -// baseline or mutation logic changes. +// TestDeploymentMutations verifies the factory registers the Version mutation +// and that it fires at every version. The version gate has no constraint, so +// the mutation always applies and rewrites the image tag. +func TestDeploymentMutations(t *testing.T) { + owner := testOwner("1.0.0") + res, err := resources.NewDeploymentResource(owner) + require.NoError(t, err) + + inspector, ok := res.(concepts.MutationInspector) + require.True(t, ok) + + assert.ElementsMatch(t, []string{"Version"}, inspector.RegisteredMutations()) + + firing, err := inspector.FiringSet() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"Version"}, firing) +} + +// TestDeploymentShape verifies the app component's Deployment as built by its +// factory for each version. The baseline carries the latest container layout; +// the Version mutation sets the image tag. Golden files for each version catch +// regressions when the baseline or mutation logic changes. func TestDeploymentShape(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, appsv1.AddToScheme(scheme)) @@ -44,22 +59,11 @@ func TestDeploymentShape(t *testing.T) { t.Run(tt.name, func(t *testing.T) { owner := testOwner(tt.version) - res, err := deployment.NewBuilder(resources.BaseDeployment(owner)). - WithMutation(deployment.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(tt.version, nil), - Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-app:%s", tt.version) - return nil - }) - return nil - }, - }). - Build() + res, err := resources.NewDeploymentResource(owner) require.NoError(t, err) - golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update)) + golden.AssertYAML(t, tt.golden, res.(golden.Previewer), + golden.WithScheme(scheme), golden.Update(*update)) }) } } diff --git a/examples/custom-resource/resources/certificate_test.go b/examples/custom-resource/resources/certificate_test.go index c58e06b4..fed35b59 100644 --- a/examples/custom-resource/resources/certificate_test.go +++ b/examples/custom-resource/resources/certificate_test.go @@ -6,10 +6,10 @@ import ( "github.com/sourcehawk/operator-component-framework/examples/custom-resource/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/static" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -25,41 +25,31 @@ func testOwner() *sharedapp.ExampleApp { return owner } -// TestCertificateShape verifies the CertificateRequest's shape after mutations -// set spec fields (issuerRef, dnsNames) and metadata labels. The golden file -// catches regressions when the mutation logic or base object changes. -func TestCertificateShape(t *testing.T) { - owner := testOwner() +// TestCertificateMutations verifies the factory registers the certificate-spec +// mutation and that it fires. The mutation has no gate, so it always applies. +func TestCertificateMutations(t *testing.T) { + res, err := resources.NewCertificateResource(testOwner()) + require.NoError(t, err) + + inspector, ok := res.(concepts.MutationInspector) + require.True(t, ok) + + assert.ElementsMatch(t, []string{"certificate-spec"}, inspector.RegisteredMutations()) - res, err := static.NewBuilder(resources.BaseCertificateRequest(owner)). - WithMutation(unstruct.Mutation{ - Name: "certificate-spec", - Mutate: func(m *unstruct.Mutator) error { - m.EditContent(func(e *editors.UnstructuredContentEditor) error { - if err := e.SetNestedString("letsencrypt-prod", "spec", "issuerRef", "name"); err != nil { - return err - } - if err := e.SetNestedString("ClusterIssuer", "spec", "issuerRef", "kind"); err != nil { - return err - } - return e.SetNestedSlice( - []interface{}{owner.Name + ".example.com"}, - "spec", "dnsNames", - ) - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app", owner.Name) - return nil - }) - - return nil - }, - }). - Build() + firing, err := inspector.FiringSet() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"certificate-spec"}, firing) +} + +// TestCertificateShape verifies the CertificateRequest as built by its factory, +// after the certificate-spec mutation sets spec fields (issuerRef, dnsNames) +// and metadata labels. The golden file catches regressions when the mutation +// logic or base object changes. +func TestCertificateShape(t *testing.T) { + res, err := resources.NewCertificateResource(testOwner()) require.NoError(t, err) - golden.AssertYAML(t, "testdata/certificate.yaml", res, golden.Update(*update)) + golden.AssertYAML(t, "testdata/certificate.yaml", res.(golden.Previewer), golden.Update(*update)) } // TestCertificateBaseShape pins the bare base object before any mutations. diff --git a/examples/extraction-and-guards/app/component_test.go b/examples/extraction-and-guards/app/component_test.go new file mode 100644 index 00000000..78126da2 --- /dev/null +++ b/examples/extraction-and-guards/app/component_test.go @@ -0,0 +1,59 @@ +package app_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/app" + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +// update is this package's own -update flag. The resources package declares its +// own; the two live in separate test binaries, so there is no conflict. +var update = flag.Bool("update", false, "update golden files") + +// scheme resolves TypeMeta for the rendered resources in the component stream. +var scheme = newScheme() + +// newScheme returns a scheme with the core and apps Kubernetes types registered. +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + panic(err) + } + return s +} + +func testOwner() *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestBuildComponent goldens the whole component the controller reconciles. The +// point of this example is data extraction feeding a guard: the ConfigMap is +// registered before the Secret, and BuildComponent owns the shared dbHost pointer +// that wires the extractor to the guard. The multi-document golden pins the +// rendered desired state of both resources, in the order the component applies +// them. The controller and this test build the component the same way, so the +// reconciled component and the snapshot stay in lockstep. +func TestBuildComponent(t *testing.T) { + controller := &app.Controller{ + NewConfigMapResource: resources.NewConfigMapResource, + NewSecretResource: resources.NewSecretResource, + } + + comp, err := controller.BuildComponent(testOwner()) + require.NoError(t, err) + + golden.AssertComponentYAML(t, "testdata/component.yaml", comp, + golden.WithScheme(scheme), golden.Update(*update)) +} diff --git a/examples/extraction-and-guards/app/controller.go b/examples/extraction-and-guards/app/controller.go index 31aae777..13f16c9a 100644 --- a/examples/extraction-and-guards/app/controller.go +++ b/examples/extraction-and-guards/app/controller.go @@ -44,28 +44,37 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro } }() + comp, err := r.BuildComponent(owner) + if err != nil { + return err + } + + return comp.Reconcile(ctx, recCtx) +} + +// BuildComponent assembles the database component: a ConfigMap registered before +// a Secret, both wired to a shared dbHost pointer. The ConfigMap extractor writes +// the pointer and the Secret guard reads it, so registration order matters. The +// controller and tests share this assembly so the reconciled component and the +// golden snapshot stay in lockstep. +func (r *Controller) BuildComponent(owner *ExampleApp) (*component.Component, error) { // Shared state: the ConfigMap extractor writes here, the Secret guard reads it. var dbHost string cmResource, err := r.NewConfigMapResource(owner, &dbHost) if err != nil { - return err + return nil, err } secretResource, err := r.NewSecretResource(owner, &dbHost) if err != nil { - return err + return nil, err } - comp, err := component.NewComponentBuilder(). + return component.NewComponentBuilder(). WithName("database"). WithConditionType("DatabaseReady"). WithResource(cmResource). WithResource(secretResource). Build() - if err != nil { - return err - } - - return comp.Reconcile(ctx, recCtx) } diff --git a/examples/extraction-and-guards/app/testdata/component.yaml b/examples/extraction-and-guards/app/testdata/component.yaml new file mode 100644 index 00000000..4a694ea9 --- /dev/null +++ b/examples/extraction-and-guards/app/testdata/component.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +data: + db-host: postgres.default.svc + db-port: "5432" +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-db-config + namespace: default +--- +apiVersion: v1 +data: + password: Y2hhbmdlbWU= + username: YXBwLXVzZXI= +kind: Secret +metadata: + labels: + app: my-app + name: my-app-db-credentials + namespace: default diff --git a/examples/extraction-and-guards/resources/configmap_test.go b/examples/extraction-and-guards/resources/configmap_test.go index 4d293e5f..fa59f3e4 100644 --- a/examples/extraction-and-guards/resources/configmap_test.go +++ b/examples/extraction-and-guards/resources/configmap_test.go @@ -6,7 +6,6 @@ import ( "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -24,17 +23,19 @@ func testOwner() *sharedapp.ExampleApp { return owner } -// TestConfigMapShape pins the database config ConfigMap's baseline shape. -// If the base object changes (e.g. new keys added or defaults changed), the -// golden file catches it so the change is reviewed explicitly. +// TestConfigMapShape pins the database config ConfigMap as built by its factory. +// The factory registers a data extractor but no mutations, so the golden file +// captures the full desired state. If the base object changes (e.g. new keys +// added or defaults changed), the golden file catches it. func TestConfigMapShape(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) owner := testOwner() - res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build() + var dbHost string + res, err := resources.NewConfigMapResource(owner, &dbHost) require.NoError(t, err) - golden.AssertYAML(t, "testdata/configmap.yaml", res, + golden.AssertYAML(t, "testdata/configmap.yaml", res.(golden.Previewer), golden.WithScheme(scheme), golden.Update(*update)) } diff --git a/examples/extraction-and-guards/resources/secret_test.go b/examples/extraction-and-guards/resources/secret_test.go index 74017c16..6fc151a8 100644 --- a/examples/extraction-and-guards/resources/secret_test.go +++ b/examples/extraction-and-guards/resources/secret_test.go @@ -4,24 +4,25 @@ import ( "testing" "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" ) -// TestSecretShape pins the database credentials Secret's baseline shape. -// The guard is not exercised here; this test only verifies the resource's -// desired state before reconciliation. +// TestSecretShape pins the database credentials Secret as built by its factory. +// The factory registers a guard but no mutations, so the golden file captures +// the full desired state. The guard is not exercised here; this test only +// verifies the resource's desired state before reconciliation. func TestSecretShape(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) owner := testOwner() - res, err := secret.NewBuilder(resources.BaseSecret(owner)).Build() + var dbHost string + res, err := resources.NewSecretResource(owner, &dbHost) require.NoError(t, err) - golden.AssertYAML(t, "testdata/secret.yaml", res, + golden.AssertYAML(t, "testdata/secret.yaml", res.(golden.Previewer), golden.WithScheme(scheme), golden.Update(*update)) } diff --git a/examples/grace-inconsistency/app/component_test.go b/examples/grace-inconsistency/app/component_test.go new file mode 100644 index 00000000..589df65d --- /dev/null +++ b/examples/grace-inconsistency/app/component_test.go @@ -0,0 +1,58 @@ +package app_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/app" + "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +// update is this package's own -update flag. The resources package declares its +// own; the two live in separate test binaries, so there is no conflict. +var update = flag.Bool("update", false, "update golden files") + +// scheme resolves TypeMeta for the rendered resources in the component stream. +var scheme = newScheme() + +// newScheme returns a scheme with the core and apps Kubernetes types registered. +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + panic(err) + } + return s +} + +func testOwner() *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestBuildComponent goldens the whole component the controller reconciles. The +// point of this example is grace-inconsistency suppression: the component carries +// a grace period and registers its Deployment with the +// SuppressGraceInconsistencyWarning resource option. The multi-document golden +// pins the rendered desired state of the resource the component applies. The +// controller and this test build the component the same way, so the reconciled +// component and the snapshot stay in lockstep. +func TestBuildComponent(t *testing.T) { + controller := &app.Controller{ + NewDeploymentResource: resources.NewDeploymentResource, + } + + comp, err := controller.BuildComponent(testOwner()) + require.NoError(t, err) + + golden.AssertComponentYAML(t, "testdata/component.yaml", comp, + golden.WithScheme(scheme), golden.Update(*update)) +} diff --git a/examples/grace-inconsistency/app/controller.go b/examples/grace-inconsistency/app/controller.go index da8d6d8f..7d76f973 100644 --- a/examples/grace-inconsistency/app/controller.go +++ b/examples/grace-inconsistency/app/controller.go @@ -40,12 +40,27 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro } }() - deployResource, err := r.NewDeploymentResource(owner) + comp, err := r.BuildComponent(owner) if err != nil { return err } - comp, err := component.NewComponentBuilder(). + return comp.Reconcile(ctx, recCtx) +} + +// BuildComponent assembles the monitoring component: a Deployment whose custom +// grace handler reports Healthy while the convergence handler may report +// non-healthy. The grace period and the SuppressGraceInconsistencyWarning option +// are the point of this example, so the controller and tests build the component +// the same way to keep the reconciled component and the golden snapshot in +// lockstep. +func (r *Controller) BuildComponent(owner *ExampleApp) (*component.Component, error) { + deployResource, err := r.NewDeploymentResource(owner) + if err != nil { + return nil, err + } + + return component.NewComponentBuilder(). WithName("monitoring"). WithConditionType("MonitoringReady"). // SuppressGraceInconsistencyWarning tells the framework not to log a @@ -55,9 +70,4 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro WithResource(deployResource, component.SuppressGraceInconsistencyWarning()). WithGracePeriod(5 * time.Second). Build() - if err != nil { - return err - } - - return comp.Reconcile(ctx, recCtx) } diff --git a/examples/grace-inconsistency/app/testdata/component.yaml b/examples/grace-inconsistency/app/testdata/component.yaml new file mode 100644 index 00000000..7be53598 --- /dev/null +++ b/examples/grace-inconsistency/app/testdata/component.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-monitoring + namespace: default +spec: + selector: + matchLabels: + app: my-app + role: monitoring + strategy: {} + template: + metadata: + labels: + app: my-app + role: monitoring + spec: + containers: + - image: prom/node-exporter:v1.3.1 + name: prometheus-exporter + resources: {} diff --git a/examples/grace-inconsistency/resources/deployment_test.go b/examples/grace-inconsistency/resources/deployment_test.go index 691f48a3..9e279a08 100644 --- a/examples/grace-inconsistency/resources/deployment_test.go +++ b/examples/grace-inconsistency/resources/deployment_test.go @@ -6,7 +6,6 @@ import ( "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/resources" sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -30,13 +29,15 @@ func testScheme() *runtime.Scheme { return s } -// TestDeploymentShape pins the monitoring Deployment's baseline. This resource -// has no mutations; changes to the base object surface as a golden file diff. +// TestDeploymentShape pins the monitoring Deployment as built by its factory. +// The factory registers a custom grace handler but no mutations, so the golden +// file captures the full desired state. Changes to the base object surface as a +// golden file diff. func TestDeploymentShape(t *testing.T) { owner := testOwner() - res, err := deployment.NewBuilder(resources.BaseDeployment(owner)).Build() + res, err := resources.NewDeploymentResource(owner) require.NoError(t, err) - golden.AssertYAML(t, "testdata/deployment.yaml", res, + golden.AssertYAML(t, "testdata/deployment.yaml", res.(golden.Previewer), golden.WithScheme(testScheme()), golden.Update(*update)) } From 1d7603c23cf83f57cf182bab6e54fc471be21887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:17:46 +0200 Subject: [PATCH 17/37] docs: restructure testing guide around the three testing layers Lead with the mutation/resource/component layering: unit-test a mutation against a baseline, assert which mutations fire via concepts.MutationInspector, and pin output with golden. Frame goldengen as the declarative coverage tool that works at both resource (goldengen.Resource) and component (goldengen.Component) granularity, with AssertComplete proving every registered mutation is tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 99 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 15 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index f732365a..c8e637c8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,18 +1,48 @@ # Testing -This page covers the two test-only packages the framework ships for asserting the desired state your resources and -components produce: `pkg/testing/golden` for single-build snapshot tests and `pkg/testing/goldengen` for version-matrix -golden generation. It is aimed at anyone writing tests for primitives or components built with this framework. +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. -## Which tool to use +## The three layers -| Situation | Tool | -| --------------------------------------------------------------- | ------------------------------------------------ | -| Pin the output of one resource or component build | `golden` | -| Assert gating across many versions for a version-gated resource | `goldengen` | -| Generate goldens from a tool outside a test body | `golden.Serialize` / `golden.SerializeComponent` | +Test a component from the inside out. Each layer asserts something the layer below cannot: -Both packages are opt-in and import nothing into the reconcile path. A consumer that does not import them pays nothing. +| 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 | `concepts.MutationInspector` and `golden`, or `goldengen.Resource` | +| **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 @@ -136,16 +166,55 @@ stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream `goldengen` is built on exactly these two functions. -## Version matrix generation +## Asserting which mutations fire + +A golden pins what a resource renders, but not which mutations produced it. To assert that the gates you expect actually +fired for a given spec, use the introspection a built primitive exposes through `concepts.MutationInspector`. Build the +resource through the same factory the reconciler uses, then inspect it: + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + +res, err := resources.NewDeploymentResource(owner) // the real factory +require.NoError(t, err) + +inspector := res.(concepts.MutationInspector) +firing, err := inspector.FiringSet() +require.NoError(t, err) +assert.ElementsMatch(t, []string{"DebugLogging"}, firing) +``` + +`RegisteredMutations()` returns the name of every mutation registered on the resource; `FiringSet()` returns the subset +whose gate is enabled for the version and flags the resource was built at. A built `*component.Component` implements the +same interface, deduplicated across its resources, which is how the component layer asserts firing across a whole +component. + +Asserting the firing set by hand is enough for a single build. To prove that _every_ registered mutation is tested, +across versions and specs, use `goldengen` below: it is built on exactly this introspection and adds a completeness +check. + +## 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 (so you do not call `FiringSet` by +hand), 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. 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` sweeps the versions you supply, groups them by which mutations fire, and writes one golden per distinct -group. +`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). -The walkthrough below follows it directly. +[`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 From 9555888705fc4bafaf31a10c0cc45446f37b87d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:21:14 +0200 Subject: [PATCH 18/37] docs: simplify getting-started resource test to a plain golden Drop the manual MutationInspector/FiringSet introspection from the tutorial step; a single golden assertion is the right altitude for getting started. The firing-set introspection and goldengen coverage are covered on the Testing page, which the step already links to. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/getting-started.md | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 8d285930..6a243c7c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -364,13 +364,9 @@ ConfigMap. ## Step 6: Test the resource -A resource test answers two questions: do the right mutations fire for a given spec, and does the rendered output match -what you expect? Build the resource through the same factory the reconciler uses, then assert both. - -`res.FiringSet()`, from `concepts.MutationInspector` (which every built-in primitive implements), returns the names of -the mutations that fire for the resource's version and flags. A golden test then pins the rendered YAML against a -checked-in snapshot: `golden.AssertYAML` does the comparison, `golden.WithScheme` makes the output carry `apiVersion` -and `kind` for typed objects, and the `-update` flag regenerates the snapshot. +Pin the resource's rendered output against a checked-in snapshot. Build it through the same factory the reconciler uses, +then golden it: `golden.AssertYAML` does the comparison, `golden.WithScheme` makes the output carry `apiVersion` and +`kind` for typed objects, and the `-update` flag regenerates the snapshot. ```go package resources_test @@ -379,9 +375,7 @@ import ( "flag" "testing" - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/runtime" @@ -404,15 +398,7 @@ func TestDeploymentResource(t *testing.T) { res, err := resources.NewDeploymentResource(owner) require.NoError(t, err) - // Assert which mutations fire for this spec. Built-in primitives implement - // concepts.MutationInspector. - inspector, ok := res.(concepts.MutationInspector) - require.True(t, ok) - firing, err := inspector.FiringSet() - require.NoError(t, err) - assert.ElementsMatch(t, []string{"DebugLogging"}, firing) - - // Pin the rendered output. The built resource implements golden.Previewer. + // The built resource implements golden.Previewer. previewer, ok := res.(golden.Previewer) require.True(t, ok) golden.AssertYAML(t, "testdata/deployment.yaml", previewer, golden.WithScheme(scheme), golden.Update(*update)) From ac65c6410173812a98563ebd5b3ca968e8b876a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:30:17 +0200 Subject: [PATCH 19/37] docs: fix testing guide snippets and a broken note admonition Build the single-resource golden example through a factory instead of an inline builder, define owner in every complete test snippet so they compile as written, and fix the -update note whose content sat on the same line as the !!! note directive (so it rendered as plain text). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index c8e637c8..af101325 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -102,12 +102,18 @@ implement `Preview` on a custom resource. var update = flag.Bool("update", false, "update golden files") func TestDeploymentGolden(t *testing.T) { - res, err := deployment.NewBuilder(baseDeployment()). - WithMutation(features.DebugLoggingMutation(true)). - Build() + 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) - golden.AssertYAML(t, "testdata/deployment.yaml", res, + // The factory returns a component.Resource; the built primitive also + // implements golden.Previewer. + previewer, ok := res.(golden.Previewer) + require.True(t, ok) + golden.AssertYAML(t, "testdata/deployment.yaml", previewer, golden.WithScheme(scheme), golden.Update(*update)) } ``` @@ -120,8 +126,10 @@ 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`. +!!! 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. @@ -133,10 +141,14 @@ stream (`---` separated, in apply order). ```go func TestComponentGolden(t *testing.T) { - c, err := buildComponent(owner) + 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", c, + golden.AssertComponentYAML(t, "testdata/component.yaml", comp, golden.WithScheme(scheme), golden.Update(*update)) } ``` @@ -175,6 +187,7 @@ resource through the same factory the reconciler uses, then inspect it: ```go import "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" +// owner is your CRD instance, with the version and flags you want to test. res, err := resources.NewDeploymentResource(owner) // the real factory require.NoError(t, err) From d5cb66704cf994cc5e21756496d6428632cb4868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:34:23 +0200 Subject: [PATCH 20/37] docs: drop the manual FiringSet section in favor of goldengen.Resource Remove the standalone 'asserting which mutations fire' section that taught the manual MutationInspector type-assert and FiringSet call; goldengen.Resource is the supported way to assert firing and it adds completeness. Keep a short note in the goldengen section explaining the MutationInspector primitive it is built on. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 44 +++++++++++--------------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index af101325..53f0fd31 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,11 +9,11 @@ testing 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 | `concepts.MutationInspector` and `golden`, or `goldengen.Resource` | -| **Component** | the whole component renders the resources you expect, applied together | `golden.AssertComponentYAML`, or `goldengen.Component` | +| 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) @@ -178,34 +178,6 @@ stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream `goldengen` is built on exactly these two functions. -## Asserting which mutations fire - -A golden pins what a resource renders, but not which mutations produced it. To assert that the gates you expect actually -fired for a given spec, use the introspection a built primitive exposes through `concepts.MutationInspector`. Build the -resource through the same factory the reconciler uses, then inspect it: - -```go -import "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - -// owner is your CRD instance, with the version and flags you want to test. -res, err := resources.NewDeploymentResource(owner) // the real factory -require.NoError(t, err) - -inspector := res.(concepts.MutationInspector) -firing, err := inspector.FiringSet() -require.NoError(t, err) -assert.ElementsMatch(t, []string{"DebugLogging"}, firing) -``` - -`RegisteredMutations()` returns the name of every mutation registered on the resource; `FiringSet()` returns the subset -whose gate is enabled for the version and flags the resource was built at. A built `*component.Component` implements the -same interface, deduplicated across its resources, which is how the component layer asserts firing across a whole -component. - -Asserting the firing set by hand is enough for a single build. To prove that _every_ registered mutation is tested, -across versions and specs, use `goldengen` below: it is built on exactly this introspection and adds a completeness -check. - ## Coverage with goldengen `goldengen` is the declarative way to do the resource and component layers when you want coverage rather than a single @@ -218,6 +190,12 @@ It works at either granularity through one `Unit` abstraction: wrap a built reso `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. From e8cda5de350fe8dcac5221a84c5a608d83834cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:35:12 +0200 Subject: [PATCH 21/37] docs: remove forward reference to FiringSet in the goldengen intro The goldengen intro named FiringSet before it was introduced (the section that explained it was removed). Drop the parenthetical; the MutationInspector note later in the section is now where the primitive is first presented. --- docs/testing.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 53f0fd31..a4572404 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -181,9 +181,8 @@ stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream ## 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 (so you do not call `FiringSet` by -hand), writes one golden per distinct firing group, and proves through `AssertComplete` that no registered mutation went -untested. +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 From 92ac5242c08c8c7fdf88ba1c40ca5678f80fec97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:46:28 +0200 Subject: [PATCH 22/37] docs: clarify that AssertComplete checks coverage, not firing AssertComplete is an accounting check (every registered mutation must be in a Requires or Exclude); it never evaluates firing. The firing is verified by the Requires assertions during Run. Spell out the division of labor, which is easy to misread. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index a4572404..c05da8db 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -345,6 +345,11 @@ violations are: 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 (a bare `Requires` fails if its mutation never fires anywhere). The two compose: `AssertComplete` +forces every mutation to be asserted, and the `Requires` it forces you to write then proves the mutation actually fires. + ### The manifest Alongside the goldens, `Run` writes `/manifest.yaml`, a reviewable coverage map: per fixture, each regime with its From ae995943876b5d12f13a9b36b264b02b49ce2d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:50:15 +0200 Subject: [PATCH 23/37] docs: add a summary table contrasting Requires, Forbids, and AssertComplete A compact table makes the division clear: Requires/Forbids assert firing during the sweep, AssertComplete asserts coverage on registration, and nothing fails merely because a mutation fired without a matching Requires. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index c05da8db..45750dd8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -347,8 +347,18 @@ 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 (a bare `Requires` fails if its mutation never fires anywhere). The two compose: `AssertComplete` -forces every mutation to be asserted, and the `Requires` it forces you to write then proves the mutation actually fires. +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 From 900541544f57c3bcd6aafb8866a8246cbe8cf08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:55:15 +0200 Subject: [PATCH 24/37] docs: format the LoadMatrix call across lines in canonical gofmt style Put the opening paren on the call line, one argument per line, and the closing paren on its own line with a trailing comma. --- docs/testing.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 45750dd8..84e3505a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -437,9 +437,11 @@ fixtures: ``` ```go -cfg, err := goldengen.LoadMatrix("testdata/matrix.yaml", +cfg, err := goldengen.LoadMatrix( + "testdata/matrix.yaml", func() *app.ExampleApp { return &app.ExampleApp{} }, - buildUnit) + buildUnit, +) require.NoError(t, err) gen := goldengen.New(cfg).WithUpdate(*update) From b33c93ef07984ad149c87cbb4cebfe9c1bc296ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:57:46 +0200 Subject: [PATCH 25/37] docs: clarify that LoadMatrix specs are shared across versions and need deep copy The YAML-loaded fixture spec is unmarshaled once at load time and reused for every version in the sweep, so the build callback must deep-copy it before setting the version, exactly like the Go Config.Build. newSpec is called once per fixture at load time, not per build invocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 84e3505a..b9f56218 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -400,9 +400,11 @@ func LoadMatrix[T any]( ) (Config[T], error) ``` -`newSpec` returns a fresh, empty spec to unmarshal each fixture into; `build` is the same callback you would set on a Go -`Config`, and it supplies the scheme by passing it to `goldengen.Resource` or `goldengen.Component`. The returned config -is validated before it is returned. +`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: From 962c7258de14945412656204222c89916032808b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:59:23 +0200 Subject: [PATCH 26/37] docs: show the buildUnit body in the LoadMatrix example Previously the LoadMatrix snippet referenced buildUnit by name only, hiding the deep copy and version-setting the prose describes. Show the full function so the shared-spec handling is visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index b9f56218..4feb6960 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -439,6 +439,19 @@ fixtures: ``` ```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{} }, From 2b91c3d07a1d77a97d1df343a5b2a013df02de2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:00:56 +0200 Subject: [PATCH 27/37] docs: explain that Run invokes the LoadMatrix build function Make the wiring explicit: LoadMatrix stores buildUnit as Config.Build and loads fixtures from the file; goldengen.New + gen.Run then call buildUnit per version and fixture. The YAML supplies the data, buildUnit supplies the build logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 4feb6960..0890a86e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -463,5 +463,10 @@ 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 a Go one: `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`. From b793711137f6438d54a5d57016e90c734cefe920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:13:28 +0200 Subject: [PATCH 28/37] docs: writing-docs pass on the testing guide Driven by fresh-reader tests for a newcomer and an experienced author: - Add an import block to the canonical golden example so it compiles, and name the test-helper package path; clarify that owner/resources/scheme are the reader's own packages and the package-level scheme. - Explain why the factory result is type-asserted to golden.Previewer, and that a built component satisfies ComponentPreviewer directly (no assertion). - Add a goldengen.Component Build example (component-level coverage), and explain that a component's registered/firing set is the deduplicated union of its resources' mutations. - Show how to chain AssertComplete across multiple generators in one package. - Cross-link buildComponent to Getting Started for building a component. - Reword the LoadMatrix wiring ('runs like one declared in Go') and fix a wrong 'defined below' reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/testing.md | 72 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 0890a86e..fd2cd25b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -96,9 +96,22 @@ 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. +`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) { @@ -109,8 +122,6 @@ func TestDeploymentGolden(t *testing.T) { res, err := resources.NewDeploymentResource(owner) require.NoError(t, err) - // The factory returns a component.Resource; the built primitive also - // implements golden.Previewer. previewer, ok := res.(golden.Previewer) require.True(t, ok) golden.AssertYAML(t, "testdata/deployment.yaml", previewer, @@ -118,6 +129,10 @@ func TestDeploymentGolden(t *testing.T) { } ``` +`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: @@ -137,7 +152,10 @@ 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). +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) { @@ -252,12 +270,31 @@ The fields: `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`. +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`). All built-in primitives satisfy both through -`generic.BaseResource`. For custom resources, see [Custom Resources](custom-resource.md) for how to implement -`MutationInspector`. +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 @@ -333,6 +370,18 @@ func TestMain(m *testing.M) { } ``` +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 @@ -464,9 +513,10 @@ 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 a Go one: `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. +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`. From bcef93e2afab4cf918d53dad96b6dd585ce6dc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:36:53 +0200 Subject: [PATCH 29/37] docs: realign the version-coverage guideline to recommend goldengen The guideline showed a hand-written one-golden-per-version loop, which the testing guide now supersedes with goldengen.Resource (firing regimes, gating assertions, AssertComplete coverage). Reframe around goldengen, keep the baseline-safety principle and the review-the-diff insight, and drop the manual loop. Retitle to 'Pin Rendered Output Across Supported Versions'. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guidelines.md | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/docs/guidelines.md b/docs/guidelines.md index 70edd667..a5f13427 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -581,28 +581,23 @@ output, after the capability, not the Kubernetes resource type backing it. A condition named `DeploymentReconciled` tells a user nothing about which capability is affected. `BackendReady` does. -## Write Golden Tests for Every Supported Version - -Every supported version should have a golden snapshot. When you update the baseline, golden tests prove that older -versions still render the object they did before, and that the change touched only the version you intended. - -```go -func TestBackendShape(t *testing.T) { - for _, version := range []string{"1.9.0", "2.0.0", "2.1.0"} { - t.Run(version, func(t *testing.T) { - app := &v1alpha1.WebApp{Spec: v1alpha1.WebAppSpec{Version: version}} - res, err := buildBackend(app) - require.NoError(t, err) - golden.AssertYAML(t, "testdata/backend-"+version+".yaml", res, golden.Update(*update)) - }) - } -} -``` - -Run `go test ./path -update` to regenerate after a deliberate baseline change, then review the diff: the current -version's golden updates; older version goldens should not. If a baseline change accidentally breaks a compat mutation, -the diff shows exactly what shifted. For sweeping every supported version and asserting mutation coverage across the -matrix, see [testing.md](testing.md). +## 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 From 83fdc7ff349b02da334100df117a5744772a48a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:43:15 +0200 Subject: [PATCH 30/37] docs: render the component architecture block as a centered Mermaid diagram Replace the hand-drawn ASCII tree with a Mermaid flowchart (consistent with the page's other diagrams and the README), and center Mermaid diagrams site-wide via extra.css. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/component.md | 13 ++++++++----- docs/stylesheets/extra.css | 11 +++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/component.md b/docs/component.md index 4ab9e554..c171ecf9 100644 --- a/docs/component.md +++ b/docs/component.md @@ -7,11 +7,14 @@ A **Component** groups related Kubernetes resources into one behavioral unit. It their shared lifecycle (feature gating, prerequisites, suspension, grace periods, guards), and reports their aggregate health through a single condition on the owner CRD. -```text -Controller - └─ Component one condition on the owner - └─ Resource Primitive Deployment, ConfigMap, Service, ... - └─ Kubernetes Object +```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). diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 23b0184b..e400104e 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -3,3 +3,14 @@ --md-primary-fg-color: #3f51b5; --md-accent-fg-color: #3f51b5; } + +/* Center Mermaid diagrams. A diagram narrower than the page is centered; + a full-width one is unaffected. */ +.mermaid { + text-align: center; +} + +.mermaid svg { + margin: 0 auto; +} + From 9c8801547b4a2d141a45fa0b219e51a02c7b223b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:43:57 +0200 Subject: [PATCH 31/37] docs: bold each node headline in the component architecture diagram --- docs/component.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/component.md b/docs/component.md index c171ecf9..ec36f834 100644 --- a/docs/component.md +++ b/docs/component.md @@ -9,10 +9,10 @@ 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["Controller"] + Component["Component
one condition on the owner"] + Primitive["Resource Primitive
Deployment, ConfigMap, Service, ..."] + Object["Kubernetes Object"] Controller --> Component --> Primitive --> Object ``` From ca25af0df42bc1d14b6f643f3874e6fe10c07521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:50:26 +0200 Subject: [PATCH 32/37] docs: clarify the WithResource-to-IncludeWhen untrack path and its owner-reference caveat Confirm that moving a resource from WithResource to IncludeWhen(false) drops it from the component without deleting it, and warn that the controller owner reference set while it was managed is not stripped, so Kubernetes still garbage-collects it when the owner is deleted unless you remove the reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/component.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/component.md b/docs/component.md index ec36f834..3e0b8681 100644 --- a/docs/component.md +++ b/docs/component.md @@ -108,8 +108,17 @@ builder.IncludeWhen(spec.ConfigRef != nil, func() component.Resource { }, component.ReadOnly(), component.BlockOnAbsence()) ``` -A secondary use is migrating a resource from tracked to untracked without deleting it: passing `include = false` leaves -an already-present resource in place, unmanaged, rather than removing it the way `GatedBy` or `DeleteWhen` would. +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. + +!!! warning + + Untracking does not remove the owner reference. While the resource was managed, the component set a controller owner + reference on it (for scope-compatible resources), and after untracking it never strips that reference. The resource + survives reconciliation, but Kubernetes still garbage-collects it when the owner is deleted. To make it outlive the + owner, remove the owner reference yourself. ## Feature Gates From d9247896554686497aa0e123d316e0b99ec064f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:51:36 +0200 Subject: [PATCH 33/37] docs: fix the plan-and-apply Mermaid sequence diagram not rendering A semicolon in the Note text terminated the Mermaid statement (Mermaid treats ; as a separator), so the whole sequenceDiagram failed to parse. Replace it with a comma. --- docs/primitives.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/primitives.md b/docs/primitives.md index 1f734200..6443a554 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -172,7 +172,7 @@ sequenceDiagram participant Mutator participant Object as Kubernetes object Author->>Builder: WithMutation(name, feature, mutate) - Note over Builder: stores the mutation; nothing is applied yet + 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 From 85cdd848b364fcab18e4a17adb2876c1ce7d251a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:55:35 +0200 Subject: [PATCH 34/37] docs: adopt OrphanWhen in the component resource-options docs Add OrphanWhen(cond) to the resource-options table, and replace the IncludeWhen 'remove the owner reference yourself' warning with a note recommending OrphanWhen for releasing a resource so it outlives its owner. Include OrphanWhen in the ReadOnly exclusivity list. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/component.md | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/component.md b/docs/component.md index 3e0b8681..e281fd99 100644 --- a/docs/component.md +++ b/docs/component.md @@ -50,21 +50,22 @@ Each resource is registered via `WithResource`. The second argument accepts zero 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.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 | +| 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.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()`, and `GatedBy()`; 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. +`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. Options compose. Gate a resource and exclude it from health aggregation in one call: @@ -113,12 +114,14 @@ A secondary use is migrating a resource from tracked to untracked without deleti 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. -!!! warning +!!! note "Untracking vs. releasing" - Untracking does not remove the owner reference. While the resource was managed, the component set a controller owner - reference on it (for scope-compatible resources), and after untracking it never strips that reference. The resource - survives reconciliation, but Kubernetes still garbage-collects it when the owner is deleted. To make it outlive the - owner, remove the owner reference yourself. + `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 From d60c57cd7ac37ab1f0d925f1cda626036b15a770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:58:58 +0200 Subject: [PATCH 35/37] docs: trim README to a minimal entry point that links to the docs site Cut the quick start, beyond-the-basics, primitives/lifecycle tables, and custom-resource section (all now covered on the docs site). Keep the pitch, the architecture diagram, one short taste, install, and a documentation section linking to the published site and pkg.go.dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 384 +++++------------------------------------------------- 1 file changed, 35 insertions(+), 349 deletions(-) diff --git a/README.md b/README.md index 08dfd271..715bb07d 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ matters. > It is a library you use inside controller-runtime reconcilers, such as in Kubebuilder-generated projects, to manage > the layers between the reconciler and the Kubernetes resources it manages. ---- - ## Architecture An operator built with this framework has two layers between the controller and raw Kubernetes objects: @@ -56,372 +54,60 @@ graph TB > ⚪ What you already have   🔵 OCF component layer   🟢 OCF primitive layer -## Features - -**Reconciliation and health** - -- **Predictable status management** with consistent condition reporting aggregated from all managed resources -- **Grace periods** allow time for resources to converge before reporting degraded or down status -- **Suspend and resume** entire components with configurable behavior (scale to zero, delete, or custom logic) -- **Lifecycle-aware primitives** for deployments, jobs, services, and more, each reporting health in a way that fits its - category - -**Feature management** - -- **Version-gated mutations** apply patches only when a version constraint matches, keeping the baseline clean -- **Stackable mutations** that compose independently on the same resource without conflicts -- **Typed editors and selectors** for modifying containers, pod specs, metadata, and other resource fields safely -- **Feature gates** to enable or disable entire components or individual resources based on flags or version ranges - -**Orchestration** - -- **Resource guards** block a resource (and everything after it) until a precondition is met -- **Data extraction** harvests values from one resource and makes them available to subsequent guards and mutations -- **Prerequisites** express startup ordering between components (e.g., "wait for the database before starting the API") -- **Metrics and event recording** integrations out of the box - -## Installation - -```bash -go get github.com/sourcehawk/operator-component-framework -``` - -Requires Go 1.25.6+ and [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) v0.22 or later. See -[Compatibility](docs/compatibility.md) for the full support matrix. - -## Quick Start - -The following example builds a component that manages a ConfigMap, Deployment, and Service together. Each resource is -built by its own function, mutations are defined separately, and a component function composes everything into a single -reconcilable unit. - -### Resource builders - -Each function returns a `component.Resource` wrapping a single Kubernetes object. The framework provides typed -[primitive builders](docs/primitives.md) for common resource types. - -```go -import ( - "time" - - 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/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/configmap" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/service" -) - -func NewWebConfig(owner *MyOperatorCR) (component.Resource, error) { - return configmap.NewBuilder(&corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "web-config", Namespace: owner.Namespace}, - Data: map[string]string{"log-level": owner.Spec.LogLevel}, - }).Build() -} - -func NewWebDeployment(owner *MyOperatorCR) (component.Resource, error) { - dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "web-server", Namespace: owner.Namespace}, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "web-server"}}, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "web-server"}}, - Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "my-app:latest"}}}, - }, - }, - } - return deployment.NewBuilder(dep). - WithMutation(TracingFeature(owner.Spec.TracingEnabled)). - WithMutation(LegacyPortConfig(owner.Spec.Version)). - Build() -} - -func NewWebService(owner *MyOperatorCR) (component.Resource, error) { - return service.NewBuilder(&corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: "web-server", Namespace: owner.Namespace}, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{"app": "web-server"}, - Ports: []corev1.ServicePort{{Port: 8080}}, - }, - }).Build() -} -``` - -### Feature mutations - -[Mutations](docs/primitives.md#mutation-system) decouple version-specific or feature-gated logic from the baseline -resource definition. Each mutation declares a condition under which it applies and a function that modifies the resource -through typed [editors](docs/primitives.md#mutation-editors) and -[container selectors](docs/primitives.md#container-selectors). - -A boolean-gated mutation applies only when a flag is set: - -```go -func TracingFeature(enabled bool) deployment.Mutation { - return deployment.Mutation{ - Name: "enable-tracing", - Feature: feature.NewVersionGate("", nil).When(enabled), - Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { - e.EnsureEnvVar(corev1.EnvVar{Name: "TRACING_ENABLED", Value: "true"}) - return nil - }) - return nil - }, - } -} -``` - -A version-gated mutation applies only when the current version satisfies a constraint. This is useful for backward -compatibility: the baseline reflects the latest shape, and mutations patch it back for older versions. - -```go -func LegacyPortConfig(version string) deployment.Mutation { - return deployment.Mutation{ - Name: "legacy-port-config", - Feature: feature.NewVersionGate(version, []feature.VersionConstraint{ - LessThan("2.0.0"), // user-provided VersionConstraint implementation - }), - Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { - e.Raw().Ports = []corev1.ContainerPort{{Name: "http", ContainerPort: 8080}} - return nil - }) - return nil - }, - } -} -``` - -Mutations are applied in registration order. Multiple mutations can target the same resource without interfering with -each other, and the framework guarantees a consistent application sequence. - -### Component - -The [component](docs/component.md) composes resources into a single reconcilable unit with one condition on the owner -object. Resources are reconciled in registration order, so the ConfigMap exists before the Deployment is applied. - -```go -func NewWebInterfaceComponent(owner *MyOperatorCR) (*component.Component, error) { - configMap, err := NewWebConfig(owner) - if err != nil { - return nil, err - } - deployment, err := NewWebDeployment(owner) - if err != nil { - return nil, err - } - service, err := NewWebService(owner) - if err != nil { - return nil, err - } - - return component.NewComponentBuilder(). - WithName("web-interface"). - WithConditionType("WebInterfaceReady"). - WithResource(configMap). - WithResource(deployment). - WithResource(service). - WithGracePeriod(5 * time.Minute). - Suspend(owner.Spec.Suspended). - Build() -} -``` - -### Reconciliation - -The controller builds the component and hands it to the framework. +A component composes resource primitives into one reconcilable unit with a single condition on the owner. The reconciler +builds it and hands it to the framework, which applies the resources, aggregates their health, and writes the condition +back. ```go -func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - owner := &MyOperatorCR{} - 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 := NewWebInterfaceComponent(owner) - if err != nil { - return reconcile.Result{}, err - } - - return reconcile.Result{}, comp.Reconcile(ctx, recCtx) +comp, err := component.NewComponentBuilder(). + WithName("web-interface"). + WithConditionType("WebInterfaceReady"). + WithResource(configMap). + WithResource(deployment). + WithResource(service). + WithGracePeriod(5 * time.Minute). + Suspend(owner.Spec.Suspended). + Build() +if err != nil { + return err } -``` - -Components stage their conditions on `owner` in memory; a single deferred `component.FlushStatus` at the end of the -reconcile loop persists every condition with one `Status().Update` call. This keeps controllers with multiple components -free of self-induced 409 conflicts. -## Beyond the Basics - -The Quick Start shows the common path. The sections below highlight capabilities that matter once your operator grows -beyond a single resource. - -### Guards and Data Extraction - -Resources are reconciled in order. A [data extractor](docs/primitives.md#lifecycle-interfaces) on an earlier resource -can feed a [guard](docs/component.md#guards) on a later one, letting you express dependencies between resources within a -single component. - -```go -func NewDatabaseConfig(owner *MyOperatorCR, dbHost *string) (component.Resource, error) { - return configmap.NewBuilder(baseCM). - WithDataExtractor(func(cm corev1.ConfigMap) error { - *dbHost = cm.Data["database-host"] - return nil - }). - Build() -} - -func NewAppDeployment(owner *MyOperatorCR, dbHost *string) (component.Resource, error) { - return deployment.NewBuilder(baseDep). - WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) { - if dbHost == nil || *dbHost == "" { - return concepts.GuardStatusWithReason{ - Status: concepts.GuardStatusBlocked, - Reason: "waiting for database host from ConfigMap", - }, nil - } - return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil - }). - Build() -} -``` - -When a guard blocks, the component reports a `Blocked` condition and skips all subsequent resources in the pipeline. - -### Component Prerequisites and Feature Gates - -[Prerequisites](docs/component.md#prerequisites) express startup ordering between components. -[Feature gates](docs/component.md#component-feature-gates) conditionally enable or disable an entire component: when -disabled, all its resources are deleted and the condition reports `Disabled`. - -```go -func NewAPIGatewayComponent(owner *MyOperatorCR) (*component.Component, error) { - // ... build gateway deployment, gateway service ... - - return component.NewComponentBuilder(). - WithName("api-gateway"). - WithConditionType("APIGatewayReady"). - WithFeatureGate(feature.NewVersionGate(version, versionConstraints).When(spec.GatewayEnabled)). - WithPrerequisite(component.DependsOn("DatabaseReady")). - WithResource(gatewayDep). - WithResource(gatewaySvc). - Build() -} +return comp.Reconcile(ctx, recCtx) ``` -### Resource Options - -Individual resources can be [feature-gated, read-only, or auxiliary](docs/component.md#resource-registration-options) -within a component. - -```go -// Feature-gated: created when enabled, deleted when disabled. -builder.WithResource(metricsExporter, component.GatedBy(metricsGate), component.Auxiliary()) -builder.WithResource(externalCRD, component.ReadOnly()) -``` - -### Built-in Primitives - -The framework ships with primitives for the most common Kubernetes resource types. Each primitive provides a typed -builder, mutation system, and the appropriate lifecycle interfaces for its category. - -| Category | Primitives | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | -| **Workload** | Deployment, StatefulSet, DaemonSet, ReplicaSet, Pod | -| **Task** | Job, CronJob | -| **Static** | ConfigMap, Secret, ServiceAccount, Role, ClusterRole, RoleBinding, ClusterRoleBinding, PodDisruptionBudget, NetworkPolicy | -| **Integration** | Service, Ingress, PersistentVolume, PersistentVolumeClaim, HorizontalPodAutoscaler | -| **Unstructured** | Static, Workload, Task, and Integration variants for any GVK without a built-in wrapper | - -For details on each primitive, see [Resource Primitives](docs/primitives.md). - -## Resource Lifecycle Interfaces - -Resource primitives implement behavioral interfaces that the component layer uses for status aggregation: - -| Interface | Behavior | Example resources | -| ----------------- | ------------------------------------------------- | -------------------------------------------- | -| `Alive` | Observable health with rolling-update awareness | Deployments, StatefulSets, DaemonSets | -| `Graceful` | Time-bounded convergence with degradation | Workloads or integrations with slow rollouts | -| `Suspendable` | Controlled deactivation (scale to zero or delete) | Workloads, task primitives | -| `Completable` | Run-to-completion tracking | Jobs | -| `Operational` | External dependency readiness | Services, Ingresses, Gateways, CronJobs | -| `DataExtractable` | Post-reconciliation data harvest | Any resource exposing status fields | -| `Guardable` | Precondition gating before resource application | Resources dependent on prior resource state | - -## Implementing a Custom Resource - -You can wrap any Kubernetes object, including custom CRDs, by implementing the `Resource` interface: - -```go -type Resource interface { - // Object returns the desired-state Kubernetes object. - Object() (client.Object, error) - - // Mutate receives the current cluster state and applies the desired state to it. - Mutate(current client.Object) error +## Installation - // Identity returns a stable string that uniquely identifies this resource. - Identity() string -} +```bash +go get github.com/sourcehawk/operator-component-framework ``` -Optionally implement any of the lifecycle interfaces (`Alive`, `Suspendable`, etc.) to participate in condition -aggregation. The framework provides generic building blocks in `pkg/generic` that handle reconciliation mechanics, -mutation sequencing, and suspension so you can wrap any custom CRD without reimplementing these from scratch. - -See the [Custom Resource Implementation Guide](docs/custom-resource.md) for a complete walkthrough. +Requires Go 1.25.6+ and [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) v0.22 or later. ## Documentation -| Document | Description | -| ------------------------------------------- | ----------------------------------------------------------------------- | -| [Component Framework](docs/component.md) | Reconciliation lifecycle, condition model, grace periods, suspension | -| [Resource Primitives](docs/primitives.md) | Primitive categories, Server-Side Apply, mutation system | -| [Custom Resources](docs/custom-resource.md) | Implementing custom resource wrappers using the generic building blocks | -| [Guidelines](docs/guidelines.md) | Recommended patterns for structuring operators built with the framework | -| [Compatibility](docs/compatibility.md) | Supported Kubernetes and controller-runtime versions, version policy | -| [Testing](docs/testing.md) | Golden snapshots and version-matrix golden generation | - -## Contributing +Full documentation, including a step-by-step tutorial, is at +**[sourcehawk.github.io/operator-component-framework](https://sourcehawk.github.io/operator-component-framework/)**. -Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request. +| Guide | What it covers | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [Getting Started](https://sourcehawk.github.io/operator-component-framework/getting-started/) | Build your first component, end to end | +| [Component](https://sourcehawk.github.io/operator-component-framework/component/) | Lifecycle, status model, grace periods, suspension, guards | +| [Primitives](https://sourcehawk.github.io/operator-component-framework/primitives/) | Typed wrappers, the mutation system, editors, feature gating | +| [Custom Resources](https://sourcehawk.github.io/operator-component-framework/custom-resource/) | Wrap your own CRDs with `pkg/generic` | +| [Guidelines](https://sourcehawk.github.io/operator-component-framework/guidelines/) | Patterns for structuring operators well | +| [Testing](https://sourcehawk.github.io/operator-component-framework/testing/) | Golden snapshots and version-matrix coverage | +| [Compatibility](https://sourcehawk.github.io/operator-component-framework/compatibility/) | Supported Kubernetes and controller-runtime versions | -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/my-feature`) -3. Commit your changes -4. Open a pull request against `main` +The full Go API reference is on [pkg.go.dev](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework). -All new code should include tests. The project uses [testify](https://github.com/stretchr/testify), -[Ginkgo](https://github.com/onsi/ginkgo) and [Gomega](https://github.com/onsi/gomega) for testing. +## Contributing -```bash -go test ./... -``` +Contributions are welcome. Open an issue to discuss significant changes before submitting a pull request. New code +should include tests; run `go test ./...` (or `make all`) before opening a PR. ## Further Reading -- [The Missing Layers in Your Kubernetes Operator](https://medium.com/@sourcehawk/the-missing-layers-in-your-kubernetes-operator-306ee8633350) - +- [The Missing Layers in Your Kubernetes Operator](https://medium.com/@sourcehawk/the-missing-layers-in-your-kubernetes-operator-306ee8633350), a walkthrough of common structural problems in Kubernetes operators and how the framework addresses them. ## License From ea9011ac2dc476fd55e2f06ef3783c33fd5110d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:06:47 +0200 Subject: [PATCH 36/37] ci: publish docs on release instead of on every default-branch merge Pull requests still build the site in strict mode to catch broken links and nav. The site now deploys to GitHub Pages only when a release is published (so it tracks the latest released version) or on manual workflow_dispatch, rather than on every merge to the default branch. --- .github/workflows/docs.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index abb122a8..d47462a6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,9 +1,12 @@ name: Docs +# Pull requests build the site in strict mode to catch broken links and nav. +# The site is published only when a release is published (so it tracks the latest +# released version), or manually via workflow_dispatch. on: - push: - branches: - - main + release: + types: + - published pull_request: workflow_dispatch: @@ -38,7 +41,7 @@ jobs: run: mkdocs build --strict - name: Upload Pages artifact - if: github.ref == 'refs/heads/main' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' uses: actions/upload-pages-artifact@v3 with: path: site @@ -46,7 +49,7 @@ jobs: deploy: name: Deploy to GitHub Pages needs: build - if: github.ref == 'refs/heads/main' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: name: github-pages From 969119c1df9a8bb7d79453b8a1a5cc0c05065270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:10:42 +0200 Subject: [PATCH 37/37] ci: scope Pages and OIDC permissions to the deploy job only Make the top-level token read-only (inherited by the PR build job, which does not need write scopes) and grant pages:write and id-token:write only to the deploy job. Addresses the least-privilege review feedback. --- .github/workflows/docs.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d47462a6..09e4a25f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,10 +10,10 @@ on: pull_request: workflow_dispatch: +# Least privilege: the top level (inherited by the PR build job) is read-only; +# the Pages and OIDC scopes are granted only to the deploy job below. permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -51,6 +51,9 @@ jobs: needs: build if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }}