diff --git a/README.md b/README.md index 37f7e669..08dfd271 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,7 @@ See the [Custom Resource Implementation Guide](docs/custom-resource.md) for a co | [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 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..22f2eb64 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,307 @@ +# Testing + +The framework ships two test-only packages for asserting the desired state your resources and components produce: + +- `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. + +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. + +Both packages are opt-in and import nothing into the reconcile path. A consumer that does not import them pays nothing. + +## Golden snapshots + +`golden` renders a built primitive or component to canonical YAML and compares it against a checked-in file. The +serialization resolves `TypeMeta` (from the object or a supplied scheme) and strips zero-value noise fields, so the +golden reflects only the meaningful desired state. + +### 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. + +```go +var update = flag.Bool("update", false, "update golden files") + +func TestDeploymentGolden(t *testing.T) { + res, err := deployment.NewBuilder(baseDeployment()). + WithMutation(features.DebugLogging(true)). + Build() + require.NoError(t, err) + + 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. + +### Assert a component + +`AssertComponentYAML` previews every resource a component would apply and serializes them into one multi-document YAML +stream (`---` separated, in apply order). + +```go +func TestComponentGolden(t *testing.T) { + c, err := buildComponent(owner) + require.NoError(t, err) + + 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. + +### Serialize out of band + +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: + +```go +data, err := golden.Serialize(obj, scheme) // one object +stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream +``` + +`goldengen` is built on exactly these two functions. + +## 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. +`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. + +### Declare the matrix + +A `Config[T]` declares the whole matrix. `T` is your fixture spec type (a custom resource, or any value your build +function accepts). + +```go +var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{ + Dir: "testdata/version_matrix", + Versions: []string{"8.7.0", "8.8.2", "8.9.0"}, + Scheme: scheme, + 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 + }, +}) +``` + +The fields: + +- **`Dir`** roots the generated goldens and the manifest. +- **`Versions`** is the version universe to sweep, in the order you supply (see [version ordering](#version-ordering)). +- **`Scheme`** resolves `TypeMeta` when serializing, exactly as `golden.WithScheme` does. +- **`Fixtures`** are the specs to build and assert. Each names its own golden subdirectory. +- **`Exclude`** (omitted above) lists registered mutation names you deliberately leave unasserted, so they do not fail + the [completeness check](#completeness-accounting). It does not affect gating or golden generation. +- **`Build`** materializes a `Unit` from a fixture spec at a version. It must apply the version to the spec so the gates + evaluate against it. Copy the spec before mutating it, since `Build` is called once per version for the same fixture. + +`Build` returns a `Unit`, the introspectable-and-renderable handle the generator works with. Adapt a built primitive +with `goldengen.Resource(res, scheme)` or a built component with `goldengen.Component(comp, scheme)`. Both delegate +rendering to `golden.Serialize` / `golden.SerializeComponent`. + +### Run the sweep + +Wire a `-update` flag through `WithUpdate` and call `Run` from a normal test: + +```go +var update = flag.Bool("update", false, "update golden files") + +func TestVersionMatrix(t *testing.T) { + gen.WithUpdate(*update) + gen.Run(t) +} +``` + +`Run` validates the config, builds every fixture at every version, asserts the gating, then writes (under `-update`) or +compares one golden per regime plus the manifest. Generate the goldens once, inspect them, then commit: + +```bash +go test ./examples/version-matrix/ -run TestVersionMatrix -update # generate +go test ./examples/version-matrix/ # verify +``` + +### Firing-set classification + +The firing set at a version is the set of registered mutations whose gate is enabled there (a mutation with no gate +fires unconditionally). A **regime** is a maximal group of swept versions sharing an identical firing set. `goldengen` +writes one golden per regime, named after the regime's representative, instead of one golden per version. + +In the example, the universe `8.7.0`, `8.8.2`, `8.9.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"] +``` + +`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. + +### 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. + +### The four assertions + +Per fixture you assert gating with `Requires` and `Forbids`, each a list of `Expect{Name, For}`. `For` is optional; when +set it must be a version drawn from `Versions`. + +| Assertion | `For` set | Meaning | +| --------------------- | --------- | ---------------------------------------------- | +| `Requires{Name}` | no | the mutation fires at **some** swept version | +| `Requires{Name, For}` | yes | the mutation fires **at that version** | +| `Forbids{Name}` | no | the mutation fires at **no** swept version | +| `Forbids{Name, For}` | yes | the mutation **does not** fire at that version | + +Pin both sides of a boundary to assert it precisely: in the example `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". + +### Completeness accounting + +`AssertComplete` proves no registered mutation slips through unasserted. Call it from `TestMain`, passing the result of +`m.Run()`: + +```go +func TestMain(m *testing.M) { + os.Exit(gen.AssertComplete(m.Run())) +} +``` + +Accounting holds when the universe of registered mutation names across all fixtures equals +`union(Requires names) ∪ Exclude`. `AssertComplete` returns the incoming code unchanged when the tests already failed (a +nonzero code) or when accounting holds; otherwise it prints the violations to stderr and returns a nonzero code. The +violations are: + +- a registered mutation that is neither required by a fixture nor listed in `Exclude` (an unasserted mutation), +- a name in `Requires` or `Exclude` that no fixture actually registers (a stale assertion), and +- a registered mutation with an empty name. + +The effect: registering a new version-gated mutation fails the suite until you either assert it with a `Requires` or +deliberately set it aside with `Exclude`. + +### The manifest + +Alongside the goldens, `Run` writes `/manifest.yaml`, a reviewable coverage map: per fixture, each regime with its +representative version, the versions it covers, and the shared firing set. + +```yaml +fixtures: + - name: default + regimes: + - representative: 8.7.0 + versions: + - 8.7.0 + - 8.8.2 + firing: + - ClusterEnv/Pre89 + - ContainerImage + - representative: 8.9.0 + versions: + - 8.9.0 + firing: + - ClusterEnv/Unified89 + - ContainerImage +``` + +Reviewing the manifest diff in a pull request shows at a glance how the gating coverage changed: a new regime, a moved +boundary, or a mutation that started or stopped firing. + +## YAML matrix loader + +The matrix can be declared in YAML instead of Go, keeping the version universe and fixtures as data while the build and +scheme stay in code. `LoadMatrix` reads the file and returns a ready-to-run `Config[T]`: + +```go +func LoadMatrix[T any]( + path string, + newSpec func() T, + build func(version string, spec T) (Unit, error), + scheme *runtime.Scheme, +) (Config[T], error) +``` + +`newSpec` returns a fresh, empty spec to unmarshal each fixture into; `build` and `scheme` are the same callbacks you +would set on a Go `Config`. The returned config is validated before it is returned. + +A matrix file mirrors `Config` minus the Go-only `build` and `scheme`. Each fixture supplies its spec either inline +under `spec:` or from an external file under `specFile:` (resolved relative to the matrix file), exactly one of the two: + +```yaml +dir: testdata/version_matrix +versions: + - "8.7.0" + - "8.8.2" + - "8.9.0" +exclude: [] +fixtures: + - name: default + spec: # inline custom resource + apiVersion: apps.example.io/v1 + kind: ExampleApp + metadata: + name: demo + namespace: default + spec: + version: 8.7.0 + requires: + - { name: ContainerImage } + - { name: ClusterEnv/Pre89, for: "8.8.2" } + - { name: ClusterEnv/Unified89, for: "8.9.0" } + forbids: + - { name: ClusterEnv/Unified89, for: "8.8.2" } + - name: tls + specFile: fixtures/tls.yaml # external custom resource + requires: + - { name: ContainerImage } +``` + +```go +cfg, err := goldengen.LoadMatrix("testdata/matrix.yaml", + func() *app.ExampleApp { return &app.ExampleApp{} }, + buildUnit, scheme) +require.NoError(t, err) + +gen := goldengen.New(cfg).WithUpdate(*update) +gen.Run(t) +``` + +`LoadMatrix` errors if a fixture sets both `spec` and `specFile` or neither, if a `for` value is not in `versions`, or +if any spec fails to unmarshal into `T`. diff --git a/examples/version-matrix/README.md b/examples/version-matrix/README.md new file mode 100644 index 00000000..4dafb5f2 --- /dev/null +++ b/examples/version-matrix/README.md @@ -0,0 +1,50 @@ +# Version Matrix + +This example demonstrates the `pkg/testing/goldengen` helper: sweeping a version universe over a single fixture, +classifying the swept versions into behaviorally-distinct gating regimes, generating one golden per regime, asserting +the gating, and proving every registered mutation is accounted for. + +## What it shows + +- **One build, swept across versions**: `resources.NewStatefulSetResource` builds a StatefulSet with three mutations. + The owner's `Spec.Version` drives every gate, so wiring that build through `goldengen.Config.Build` and listing a + 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. +- **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`. +- **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. + +## Generated artifacts + +``` +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 } +``` + +## Running + +Generate or refresh the goldens and the manifest: + +```bash +go test ./examples/version-matrix/ -run TestVersionMatrix -update +``` + +Verify against the committed goldens: + +```bash +go test ./examples/version-matrix/ +``` + +See [docs/testing.md](../../docs/testing.md) for the full goldengen reference. diff --git a/examples/version-matrix/app/owner.go b/examples/version-matrix/app/owner.go new file mode 100644 index 00000000..dd6f1214 --- /dev/null +++ b/examples/version-matrix/app/owner.go @@ -0,0 +1,21 @@ +// Package app re-exports the shared ExampleApp CRD for the version-matrix example. +package app + +import ( + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme diff --git a/examples/version-matrix/resources/statefulset.go b/examples/version-matrix/resources/statefulset.go new file mode 100644 index 00000000..6aefdecf --- /dev/null +++ b/examples/version-matrix/resources/statefulset.go @@ -0,0 +1,117 @@ +// Package resources builds the version-gated StatefulSet for the version-matrix example. +package resources + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/sourcehawk/operator-component-framework/examples/version-matrix/app" + "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/statefulset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// semverConstraint adapts a Masterminds/semver constraint to feature.VersionConstraint. +type semverConstraint struct { + c *semver.Constraints +} + +// mustConstraint parses a semver constraint expression or panics. It is only used +// at package init with literal expressions, so a parse failure is a programming +// error rather than a runtime condition. +func mustConstraint(expr string) feature.VersionConstraint { + c, err := semver.NewConstraint(expr) + if err != nil { + panic(err) + } + return &semverConstraint{c: c} +} + +// Enabled reports whether the constraint is satisfied for the given version. +func (s *semverConstraint) Enabled(version string) (bool, error) { + v, err := semver.NewVersion(version) + if err != nil { + return false, fmt.Errorf("parse version %q: %w", version, err) + } + return s.c.Check(v), nil +} + +// BaseStatefulSet returns the desired-state StatefulSet for the given owner before +// any version-gated mutation is applied. +func BaseStatefulSet(owner *app.ExampleApp) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-db", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: owner.Name, + 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: "db"}}, + }, + }, + }, + } +} + +// ContainerImageMutation pins the container image to the owner's version. It has no +// gate, so it fires at every version and anchors the always-on part of the firing +// set. +func ContainerImageMutation(owner *app.ExampleApp) statefulset.Mutation { + return statefulset.Mutation{ + Name: "ContainerImage", + Mutate: func(m *statefulset.Mutator) error { + m.EditContainers(selectors.AllContainers(), func(c *editors.ContainerEditor) error { + c.Raw().Image = fmt.Sprintf("example/db:%s", owner.Spec.Version) + return nil + }) + return nil + }, + } +} + +// 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 { + return statefulset.Mutation{ + Name: "ClusterEnv/Pre89", + Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint("< 8.9.0")}), + Mutate: func(m *statefulset.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "legacy-gossip"}) + 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 { + return statefulset.Mutation{ + Name: "ClusterEnv/Unified89", + Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint(">= 8.9.0")}), + Mutate: func(m *statefulset.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "unified-raft"}) + return nil + }, + } +} + +// NewStatefulSetResource builds the StatefulSet for the owner with its version-gated +// mutations registered. The owner's Spec.Version drives every gate, so the same +// build wired through goldengen produces a distinct golden per gating regime. +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)). + Build() +} diff --git a/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml b/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml new file mode 100644 index 00000000..fee8dce3 --- /dev/null +++ b/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + labels: + app: demo + name: demo-db + namespace: default +spec: + selector: + matchLabels: + app: demo + serviceName: demo + template: + metadata: + labels: + app: demo + spec: + containers: + - env: + - name: CLUSTER_DISCOVERY + value: legacy-gossip + image: example/db:8.7.0 + name: db + resources: {} + updateStrategy: {} +status: + availableReplicas: 0 + replicas: 0 diff --git a/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml b/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml new file mode 100644 index 00000000..9c0aa59f --- /dev/null +++ b/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + labels: + app: demo + name: demo-db + namespace: default +spec: + selector: + matchLabels: + app: demo + serviceName: demo + template: + metadata: + labels: + app: demo + spec: + containers: + - env: + - name: CLUSTER_DISCOVERY + value: unified-raft + image: example/db:8.9.0 + name: db + resources: {} + updateStrategy: {} +status: + availableReplicas: 0 + replicas: 0 diff --git a/examples/version-matrix/testdata/version_matrix/manifest.yaml b/examples/version-matrix/testdata/version_matrix/manifest.yaml new file mode 100644 index 00000000..24c3413f --- /dev/null +++ b/examples/version-matrix/testdata/version_matrix/manifest.yaml @@ -0,0 +1,16 @@ +fixtures: +- name: default + regimes: + - firing: + - ClusterEnv/Pre89 + - ContainerImage + representative: 8.7.0 + versions: + - 8.7.0 + - 8.8.2 + - firing: + - ClusterEnv/Unified89 + - ContainerImage + representative: 8.9.0 + versions: + - 8.9.0 diff --git a/examples/version-matrix/version_matrix_test.go b/examples/version-matrix/version_matrix_test.go new file mode 100644 index 00000000..6fb4f669 --- /dev/null +++ b/examples/version-matrix/version_matrix_test.go @@ -0,0 +1,87 @@ +// Package versionmatrix is a worked example of goldengen: it sweeps a version +// universe over a single StatefulSet fixture, classifies the versions into gating +// regimes, generates one golden per regime, asserts the gating, and proves every +// registered mutation is accounted for. +package versionmatrix + +import ( + "flag" + "os" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/version-matrix/app" + "github.com/sourcehawk/operator-component-framework/examples/version-matrix/resources" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +// update is wired to the -update flag so the generator overwrites goldens and the +// manifest when set: go test ./examples/version-matrix/ -run TestVersionMatrix -update. +var update = flag.Bool("update", false, "update golden files") + +// scheme resolves TypeMeta for the rendered StatefulSet. +var scheme = newScheme() + +// newScheme returns a scheme with the apps and core Kubernetes types plus the +// example CRD registered. +func newScheme() *runtime.Scheme { + s := clientgoscheme.Scheme + if err := app.AddToScheme(s); err != nil { + panic(err) + } + return s +} + +// defaultCluster returns the fixture spec swept across versions. The Build function +// overwrites Spec.Version per version, so the Version set here is just a placeholder. +func defaultCluster() *app.ExampleApp { + return &app.ExampleApp{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "default"}, + Spec: app.ExampleAppSpec{Version: "0.0.0"}, + } +} + +// gen declares the whole version matrix. The Versions are listed ascending so a +// 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"}, + Scheme: scheme, + 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 + }, + 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 + }, + }}, + 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 + }, +}) + +// TestVersionMatrix runs the sweep: it asserts the gating expectations and writes or +// compares one golden per regime plus the coverage manifest. +func TestVersionMatrix(t *testing.T) { + gen.WithUpdate(*update) + gen.Run(t) +} + +// TestMain runs the package tests, then proves every registered mutation is required +// or excluded before reporting the final exit code. +func TestMain(m *testing.M) { + os.Exit(gen.AssertComplete(m.Run())) +} diff --git a/pkg/testing/goldengen/accounting_test.go b/pkg/testing/goldengen/accounting_test.go new file mode 100644 index 00000000..3bd0f466 --- /dev/null +++ b/pkg/testing/goldengen/accounting_test.go @@ -0,0 +1,87 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" +) + +// configWithRegistered builds a Config whose Build registers a StatefulSet with the +// given mutation names, attaching the given Requires to a single fixture and the +// given Exclude to the config. +func configWithRegistered(t *testing.T, registered, requires, exclude []string) goldengen.Config[*struct{}] { + t.Helper() + reqs := make([]goldengen.Expect, 0, len(requires)) + for _, name := range requires { + reqs = append(reqs, goldengen.Expect{Name: name}) + } + return goldengen.Config[*struct{}]{ + Dir: "testdata/accounting", + Versions: []string{"1.0.0"}, + Exclude: exclude, + Scheme: testScheme(), + Fixtures: []goldengen.Fixture[*struct{}]{{ + Name: "default", + Spec: &struct{}{}, + Requires: reqs, + }}, + Build: func(_ string, _ *struct{}) (goldengen.Unit, error) { + muts := make([]statefulset.Mutation, 0, len(registered)) + for _, name := range registered { + muts = append(muts, statefulset.Mutation(feature.Mutation[*statefulset.Mutator]{ + Name: name, + Mutate: func(*statefulset.Mutator) error { return nil }, + })) + } + res, err := statefulset.NewBuilder(baseStatefulSet()). + WithMutation(muts...). + Build() + if err != nil { + return nil, err + } + return goldengen.Resource(res, testScheme()), nil + }, + } +} + +func TestAssertComplete(t *testing.T) { + t.Run("complete passes", func(t *testing.T) { + gen := goldengen.New(configWithRegistered(t, []string{"A", "B"}, []string{"A"}, []string{"B"})) + assert.Equal(t, 0, gen.AssertComplete(0)) + }) + + t.Run("unaccounted fails", func(t *testing.T) { + gen := goldengen.New(configWithRegistered(t, []string{"A", "B"}, []string{"A"}, nil)) + assert.NotEqual(t, 0, gen.AssertComplete(0)) // B is neither required nor excluded + }) + + t.Run("stale exclude fails", func(t *testing.T) { + gen := goldengen.New(configWithRegistered(t, []string{"A"}, []string{"A"}, []string{"Ghost"})) + assert.NotEqual(t, 0, gen.AssertComplete(0)) // Ghost is excluded but never registered + }) + + t.Run("stale requires fails", func(t *testing.T) { + gen := goldengen.New(configWithRegistered(t, []string{"A"}, []string{"A", "Ghost"}, nil)) + assert.NotEqual(t, 0, gen.AssertComplete(0)) // Ghost is required but never registered + }) + + t.Run("passes through nonzero code", func(t *testing.T) { + gen := goldengen.New(configWithRegistered(t, []string{"A"}, []string{"A"}, nil)) + assert.Equal(t, 7, gen.AssertComplete(7)) // accounting holds, but tests already failed + }) + + t.Run("nonzero code wins over accounting failure", func(t *testing.T) { + gen := goldengen.New(configWithRegistered(t, []string{"A", "B"}, []string{"A"}, nil)) + assert.Equal(t, 7, gen.AssertComplete(7)) // incoming nonzero is returned unchanged + }) + + t.Run("invalid config fails", func(t *testing.T) { + cfg := configWithRegistered(t, []string{"A"}, []string{"A"}, nil) + cfg.Versions = nil + gen := goldengen.New(cfg) + assert.NotEqual(t, 0, gen.AssertComplete(0)) + }) +} diff --git a/pkg/testing/goldengen/generator.go b/pkg/testing/goldengen/generator.go index 2792be3e..e165b982 100644 --- a/pkg/testing/goldengen/generator.go +++ b/pkg/testing/goldengen/generator.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "testing" ) @@ -26,6 +27,79 @@ func (g *Generator[T]) WithUpdate(enabled bool) *Generator[T] { return g } +// AssertComplete proves every registered mutation is accounted for and returns an +// exit code for the consumer's TestMain to pass to os.Exit: +// +// func TestMain(m *testing.M) { os.Exit(gen.AssertComplete(m.Run())) } +// +// Accounting holds when the universe of registered mutation Names across all +// fixtures equals union(Requires names across fixtures) ∪ Exclude, with no empty +// names. The registered universe is gathered by building each fixture once at the +// first version, since registration is version-independent. +// +// It returns code unchanged when that code is already nonzero (the tests already +// failed, so accounting noise would only obscure the failure) or when accounting +// holds. Otherwise it prints the violations to stderr and returns a nonzero code. +// Violations are: a registered mutation neither required nor excluded; a stale +// Exclude or Requires name not registered by any fixture; and an empty registered +// mutation Name. +func (g *Generator[T]) AssertComplete(code int) int { + if code != 0 { + return code + } + if err := g.cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "goldengen: invalid config: %v\n", err) + return 1 + } + + registered := map[string]struct{}{} + for _, f := range g.cfg.Fixtures { + unit, err := g.cfg.Build(g.cfg.Versions[0], f.Spec) + if err != nil { + fmt.Fprintf(os.Stderr, "goldengen: build fixture %q for accounting: %v\n", f.Name, err) + return 1 + } + for _, name := range unit.RegisteredMutations() { + if name == "" { + fmt.Fprintf(os.Stderr, "goldengen: fixture %q registers a mutation with an empty Name\n", f.Name) + return 1 + } + registered[name] = struct{}{} + } + } + + accounted := map[string]struct{}{} + for _, f := range g.cfg.Fixtures { + for _, e := range f.Requires { + accounted[e.Name] = struct{}{} + } + } + for _, name := range g.cfg.Exclude { + accounted[name] = struct{}{} + } + + var violations []string + for name := range registered { + if _, ok := accounted[name]; !ok { + violations = append(violations, fmt.Sprintf("unaccounted mutation %q (require it in a fixture or add it to Exclude)", name)) + } + } + for name := range accounted { + if _, ok := registered[name]; !ok { + violations = append(violations, fmt.Sprintf("stale name %q is required or excluded but not registered by any fixture", name)) + } + } + + if len(violations) > 0 { + sort.Strings(violations) + for _, v := range violations { + fmt.Fprintf(os.Stderr, "goldengen: %s\n", v) + } + return 1 + } + return code +} + // fixtureSweep holds, per fixture, the per-version firing-sets and the built units // used to render representatives. type fixtureSweep struct {