From 69db42e6ff919895a8cb3fbc0628624957d7f8fb 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 20:11:36 +0200 Subject: [PATCH 01/12] docs: design spec for workload-kind-agnostic mutator interface Captures the design for issue #142: a shared primitives.WorkloadMutator interface plus per-kind LiftMutation adapters, so consumers can write one workload-kind-agnostic mutation and apply it across StatefulSet, Deployment, and DaemonSet. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-01-workload-mutator-interface-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md diff --git a/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md b/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md new file mode 100644 index 00000000..8f4bf31e --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md @@ -0,0 +1,156 @@ +# Workload-kind-agnostic mutator interface + +Issue: https://github.com/sourcehawk/operator-component-framework/issues/142 + +## Problem + +`*statefulset.Mutator`, `*deployment.Mutator`, and `*daemonset.Mutator` expose an +almost identical env/container/podspec/metadata editing surface, but they are +unrelated concrete types. The only framework interface spanning them, +`generic.FeatureMutator`, covers just `Apply()` and `NextFeature()`, not the +editing methods. + +A consumer that wants one workload-kind-agnostic mutation (for example, a shared +"emit these auth/license/storage env vars on the app container" helper rendered as +a StatefulSet by one component and a Deployment by others) cannot express it +against a framework type. The helper must either be duplicated per workload kind or +routed through a consumer-defined structural interface that mirrors the framework's +method set by hand and silently drifts when the framework changes. + +## Solution + +Export a framework interface, `primitives.WorkloadMutator`, carrying the shared +editing surface, and a small per-kind adapter that lifts a +`feature.Mutation[primitives.WorkloadMutator]` into each kind's `Mutation` type. + +### 1. New package `pkg/primitives` (`package primitives`) + +A new top-level package holding the interface. It imports only +`mutation/editors`, `mutation/selectors`, and `corev1`. It never imports its own +subpackages, so the existing `statefulset -> primitives`, +`deployment -> primitives`, and `daemonset -> primitives` import direction stays +acyclic. + +```go +// WorkloadMutator is the editing surface shared by every pod-workload mutator +// (*statefulset.Mutator, *deployment.Mutator, *daemonset.Mutator). It lets a +// consumer write one workload-kind-agnostic mutation and apply it to any of them. +type WorkloadMutator interface { + EditContainers(selectors.ContainerSelector, func(*editors.ContainerEditor) error) + EditInitContainers(selectors.ContainerSelector, func(*editors.ContainerEditor) error) + EnsureContainer(corev1.Container) + RemoveContainer(string) + RemoveContainers([]string) + EnsureInitContainer(corev1.Container) + RemoveInitContainer(string) + RemoveInitContainers([]string) + EditPodSpec(func(*editors.PodSpecEditor) error) + EditPodTemplateMetadata(func(*editors.ObjectMetaEditor) error) + EditObjectMetadata(func(*editors.ObjectMetaEditor) error) + EnsureContainerEnvVar(corev1.EnvVar) + RemoveContainerEnvVar(string) + RemoveContainerEnvVars([]string) + EnsureContainerArg(string) + RemoveContainerArg(string) + RemoveContainerArgs([]string) +} +``` + +Deliberately excluded: + +- `Apply()` / `NextFeature()`: framework lifecycle, not an emitter's concern. The + interface stays a pure editing contract. +- `EditStatefulSetSpec` / `EditDeploymentSpec` / `EditDaemonSetSpec`: kind-specific + editor return types. +- `EnsureReplicas`: absent on the daemonset mutator (DaemonSets have no replicas). +- `EnsureVolumeClaimTemplate` / `RemoveVolumeClaimTemplate`: StatefulSet only. + +The result is exactly the intersection across all three existing pod-workload +kinds. A future replica-less kind (for example, a Job-backed workload) can join +without changing the contract. + +### 2. Compile-time conformance guards + +In each of the three primitive packages: + +```go +var _ primitives.WorkloadMutator = (*Mutator)(nil) +``` + +These live in the child packages, not the parent (the parent importing children +would cycle). They are the key advantage over a consumer-maintained mirror: a +future rename or removal of a shared method breaks the build here, inside the +framework, instead of drifting silently in a downstream operator. + +### 3. Per-kind `LiftMutation` adapters + +In each of `statefulset`, `deployment`, `daemonset`: + +```go +// LiftMutation adapts a workload-kind-agnostic mutation into a Mutation +// so it can be registered with WithMutation. Name and Feature gating carry over +// unchanged. A nil Mutate is preserved so ApplyIntent still reports it by name. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} +``` + +`Mutation` is the package's defined type `type Mutation feature.Mutation[*Mutator]`, +which is exactly what each builder's `WithMutation` accepts. + +Call site for the issue's scenario: + +```go +func emitAuthEnv() feature.Mutation[primitives.WorkloadMutator] { /* one emitter */ } + +zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) +gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) +``` + +## Testing + +### Per-kind `LiftMutation` tests (statefulset, deployment, daemonset) + +- Name and Feature carry over unchanged. +- The lifted `Mutate` invokes the original with the concrete `*Mutator`, asserted + through an edit that lands on the object after `Apply()`. +- Gating is respected: a disabled `feature.Gate` makes the lifted mutation a no-op + through `ApplyIntent`. +- A nil `Mutate` is preserved (the lifted `Mutate` stays nil, so `ApplyIntent` + reports `mutation handler of is nil` rather than panicking). + +### Cross-kind behavioral test + +A single `func() feature.Mutation[primitives.WorkloadMutator]` emitter lifted into +both a StatefulSet and a Deployment, applied, asserting the same env var lands on +the app container of both. This guards the feature's actual intent: one emitter, +two workload kinds. + +### Conformance + +The compile-time `var _` guards are the conformance check and need no runtime test. + +## Documentation and housekeeping + +Updated in the same change as the code: + +- `docs/primitives.md`: a subsection documenting `primitives.WorkloadMutator` and + the `LiftMutation` pattern, with the shared-emitter example. Run `make fmt-md`. +- `CLAUDE.md`: add the new `pkg/primitives` top-level package to the "Source to + read" list. +- `examples/`: grep for a natural spot. If an existing example renders both a + Deployment and a StatefulSet, add a short shared-emitter usage. Otherwise the + `docs/primitives.md` example suffices and no new example directory is added. + Confirm `make build-examples` stays green. + +No E2E tests: this is a compile-time and type-plumbing change with no new +reconciliation behavior. Unit and cross-kind behavioral tests cover the intent. + +## Verification + +- `make all` (fmt, lint, test) green. +- `make build-examples` green. From a4f349f4cbf0012303b0738c6d6143f3a795a2a4 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 20:38:07 +0200 Subject: [PATCH 02/12] feat(primitives): shared WorkloadMutator interface with conformance guards Co-Authored-By: Claude Sonnet 4.6 --- pkg/primitives/daemonset/mutator.go | 6 ++++ pkg/primitives/deployment/mutator.go | 6 ++++ pkg/primitives/statefulset/mutator.go | 6 ++++ pkg/primitives/workload_mutator.go | 44 +++++++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 pkg/primitives/workload_mutator.go diff --git a/pkg/primitives/daemonset/mutator.go b/pkg/primitives/daemonset/mutator.go index 254e6bef..78e169c5 100644 --- a/pkg/primitives/daemonset/mutator.go +++ b/pkg/primitives/daemonset/mutator.go @@ -4,6 +4,7 @@ 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" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ) @@ -12,6 +13,11 @@ import ( // only if its associated feature.VersionGate is enabled. type Mutation feature.Mutation[*Mutator] +// Compile-time guarantee that *Mutator satisfies the shared workload editing +// surface. If a future change renames or removes a shared method, this breaks +// the build here instead of drifting silently in downstream consumers. +var _ primitives.WorkloadMutator = (*Mutator)(nil) + type containerEdit struct { selector selectors.ContainerSelector edit func(*editors.ContainerEditor) error diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index 82511bfe..d0667425 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -4,6 +4,7 @@ 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" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ) @@ -12,6 +13,11 @@ import ( // only if its associated feature.VersionGate is enabled. type Mutation feature.Mutation[*Mutator] +// Compile-time guarantee that *Mutator satisfies the shared workload editing +// surface. If a future change renames or removes a shared method, this breaks +// the build here instead of drifting silently in downstream consumers. +var _ primitives.WorkloadMutator = (*Mutator)(nil) + type containerEdit struct { selector selectors.ContainerSelector edit func(*editors.ContainerEditor) error diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index ec89d261..852ed2ee 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -4,6 +4,7 @@ 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" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ) @@ -12,6 +13,11 @@ import ( // only if its associated feature.VersionGate is enabled. type Mutation feature.Mutation[*Mutator] +// Compile-time guarantee that *Mutator satisfies the shared workload editing +// surface. If a future change renames or removes a shared method, this breaks +// the build here instead of drifting silently in downstream consumers. +var _ primitives.WorkloadMutator = (*Mutator)(nil) + type containerEdit struct { selector selectors.ContainerSelector edit func(*editors.ContainerEditor) error diff --git a/pkg/primitives/workload_mutator.go b/pkg/primitives/workload_mutator.go new file mode 100644 index 00000000..e43985a4 --- /dev/null +++ b/pkg/primitives/workload_mutator.go @@ -0,0 +1,44 @@ +// Package primitives hosts cross-kind contracts shared by the concrete primitive +// packages under pkg/primitives. It depends only on the mutation editor and +// selector packages, never on its own subpackages, so the subpackages can import +// it without creating an import cycle. +package primitives + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + corev1 "k8s.io/api/core/v1" +) + +// WorkloadMutator is the editing surface shared by every pod-workload mutator: +// *statefulset.Mutator, *deployment.Mutator, and *daemonset.Mutator. It lets a +// consumer express one workload-kind-agnostic mutation (for example, emitting a +// shared set of environment variables on the application container) and apply it +// to any of those kinds through the per-package LiftMutation adapters. +// +// It is exactly the intersection of the three concrete mutators' editing methods. +// Kind-specific operations are intentionally excluded and remain on the concrete +// types: the spec editors (EditStatefulSetSpec, EditDeploymentSpec, +// EditDaemonSetSpec), EnsureReplicas (absent from the DaemonSet mutator, which +// has no replica field), and the StatefulSet-only VolumeClaimTemplate methods. The lifecycle methods Apply and +// NextFeature are also excluded; they are driven by the framework, not by an +// emitter. +type WorkloadMutator interface { + EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) + EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) + EnsureContainer(container corev1.Container) + RemoveContainer(name string) + RemoveContainers(names []string) + EnsureInitContainer(container corev1.Container) + RemoveInitContainer(name string) + RemoveInitContainers(names []string) + EditPodSpec(edit func(*editors.PodSpecEditor) error) + EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) + EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) + EnsureContainerEnvVar(ev corev1.EnvVar) + RemoveContainerEnvVar(name string) + RemoveContainerEnvVars(names []string) + EnsureContainerArg(arg string) + RemoveContainerArg(arg string) + RemoveContainerArgs(args []string) +} From b604eabc765844558d82f0558b9ab62654b5227a 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 20:44:48 +0200 Subject: [PATCH 03/12] feat(statefulset): LiftMutation adapter for WorkloadMutator Co-Authored-By: Claude Sonnet 4.6 --- pkg/primitives/statefulset/mutator.go | 14 +++ pkg/primitives/statefulset/mutator_test.go | 99 ++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index 852ed2ee..fab28897 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -522,3 +522,17 @@ func applyVolumeClaimTemplateOp(vcts *[]corev1.PersistentVolumeClaim, op volumeC *vcts = append(*vcts, *op.pvc) } } + +// LiftMutation adapts a workload-kind-agnostic mutation into a StatefulSet +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged: when Feature is non-nil and enabled, the +// lifted Mutation behaves identically to one constructed directly against +// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name +// rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go index e08959c5..2afceae1 100644 --- a/pkg/primitives/statefulset/mutator_test.go +++ b/pkg/primitives/statefulset/mutator_test.go @@ -4,8 +4,10 @@ import ( "errors" "testing" + "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -772,3 +774,100 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { require.Len(t, sts.Spec.VolumeClaimTemplates, 1) }) } + +// stubGate is a test-only feature.Gate that returns a fixed boolean. +type stubGate bool + +func (g stubGate) Enabled() (bool, error) { return bool(g), nil } + +func newSingleContainerSTS() *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } +} + +func TestLiftMutation_CarriesAndInvokes(t *testing.T) { + called := false + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "emit-env", + Mutate: func(m primitives.WorkloadMutator) error { + called = true + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, "emit-env", lifted.Name) + assert.Nil(t, lifted.Feature) + require.NotNil(t, lifted.Mutate) + + sts := newSingleContainerSTS() + m := NewMutator(sts) + require.NoError(t, lifted.Mutate(m)) + require.NoError(t, m.Apply()) + + assert.True(t, called) + require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateEnabledApplies(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(true), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + conv := feature.Mutation[*Mutator](lifted) + + sts := newSingleContainerSTS() + m := NewMutator(sts) + require.NoError(t, conv.ApplyIntent(m)) + require.NoError(t, m.Apply()) + + require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(false), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, stubGate(false), lifted.Feature) + + sts := newSingleContainerSTS() + m := NewMutator(sts) + conv := feature.Mutation[*Mutator](lifted) + require.NoError(t, conv.ApplyIntent(m)) + require.NoError(t, m.Apply()) + + assert.Empty(t, sts.Spec.Template.Spec.Containers[0].Env) +} + +func TestLiftMutation_NilMutatePreserved(t *testing.T) { + lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) + assert.Nil(t, lifted.Mutate) + + conv := feature.Mutation[*Mutator](lifted) + err := conv.ApplyIntent(NewMutator(newSingleContainerSTS())) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") +} From 6ab9347e0393cfcfe5748074e7cfa0ee78d1f6cf 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 20:53:22 +0200 Subject: [PATCH 04/12] feat(deployment): LiftMutation adapter for WorkloadMutator Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/primitives/deployment/mutator.go | 14 ++++ pkg/primitives/deployment/mutator_test.go | 99 +++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index d0667425..1c2eb5e1 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -444,6 +444,20 @@ func (m *Mutator) Apply() error { return nil } +// LiftMutation adapts a workload-kind-agnostic mutation into a Deployment +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged: when Feature is non-nil and enabled, the +// lifted Mutation behaves identically to one constructed directly against +// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name +// rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} + func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { found := -1 for i, c := range *containers { diff --git a/pkg/primitives/deployment/mutator_test.go b/pkg/primitives/deployment/mutator_test.go index 973ceb59..594e9a77 100644 --- a/pkg/primitives/deployment/mutator_test.go +++ b/pkg/primitives/deployment/mutator_test.go @@ -4,8 +4,10 @@ import ( "errors" "testing" + "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -657,3 +659,100 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { assert.Equal(t, "init-1-renamed", deploy.Spec.Template.Spec.InitContainers[0].Name) assert.Equal(t, "v1-final", deploy.Spec.Template.Spec.InitContainers[0].Image) } + +// stubGate is a test-only feature.Gate that returns a fixed boolean. +type stubGate bool + +func (g stubGate) Enabled() (bool, error) { return bool(g), nil } + +func newSingleContainerDeployment() *appsv1.Deployment { + return &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } +} + +func TestLiftMutation_CarriesAndInvokes(t *testing.T) { + called := false + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "emit-env", + Mutate: func(m primitives.WorkloadMutator) error { + called = true + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, "emit-env", lifted.Name) + assert.Nil(t, lifted.Feature) + require.NotNil(t, lifted.Mutate) + + dep := newSingleContainerDeployment() + m := NewMutator(dep) + require.NoError(t, lifted.Mutate(m)) + require.NoError(t, m.Apply()) + + assert.True(t, called) + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateEnabledApplies(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(true), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + conv := feature.Mutation[*Mutator](lifted) + + dep := newSingleContainerDeployment() + m := NewMutator(dep) + require.NoError(t, conv.ApplyIntent(m)) + require.NoError(t, m.Apply()) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(false), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, stubGate(false), lifted.Feature) + conv := feature.Mutation[*Mutator](lifted) + + dep := newSingleContainerDeployment() + m := NewMutator(dep) + require.NoError(t, conv.ApplyIntent(m)) + require.NoError(t, m.Apply()) + + assert.Empty(t, dep.Spec.Template.Spec.Containers[0].Env) +} + +func TestLiftMutation_NilMutatePreserved(t *testing.T) { + lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) + assert.Nil(t, lifted.Mutate) + conv := feature.Mutation[*Mutator](lifted) + + err := conv.ApplyIntent(NewMutator(newSingleContainerDeployment())) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") +} From 97cbdbf111efb8ed27b3be163a181551e5002169 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 20:56:47 +0200 Subject: [PATCH 05/12] feat(daemonset): LiftMutation adapter for WorkloadMutator Co-Authored-By: Claude Sonnet 4.6 --- pkg/primitives/daemonset/mutator.go | 14 ++++ pkg/primitives/daemonset/mutator_test.go | 99 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/pkg/primitives/daemonset/mutator.go b/pkg/primitives/daemonset/mutator.go index 78e169c5..f7d9a87a 100644 --- a/pkg/primitives/daemonset/mutator.go +++ b/pkg/primitives/daemonset/mutator.go @@ -436,6 +436,20 @@ func (m *Mutator) Apply() error { return nil } +// LiftMutation adapts a workload-kind-agnostic mutation into a DaemonSet +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged: when Feature is non-nil and enabled, the +// lifted Mutation behaves identically to one constructed directly against +// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name +// rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} + func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { found := -1 for i, c := range *containers { diff --git a/pkg/primitives/daemonset/mutator_test.go b/pkg/primitives/daemonset/mutator_test.go index 3c158f8e..71d084a1 100644 --- a/pkg/primitives/daemonset/mutator_test.go +++ b/pkg/primitives/daemonset/mutator_test.go @@ -4,8 +4,10 @@ import ( "errors" "testing" + "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -13,6 +15,23 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// stubGate is a test-only feature.Gate that returns a fixed boolean. +type stubGate bool + +func (g stubGate) Enabled() (bool, error) { return bool(g), nil } + +func newSingleContainerDaemonSet() *appsv1.DaemonSet { + return &appsv1.DaemonSet{ + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } +} + func TestMutator_EnvVars(t *testing.T) { ds := &appsv1.DaemonSet{ Spec: appsv1.DaemonSetSpec{ @@ -625,3 +644,83 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { assert.Equal(t, "init-1-renamed", ds.Spec.Template.Spec.InitContainers[0].Name) assert.Equal(t, "v1-final", ds.Spec.Template.Spec.InitContainers[0].Image) } + +func TestLiftMutation_CarriesAndInvokes(t *testing.T) { + called := false + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "emit-env", + Mutate: func(m primitives.WorkloadMutator) error { + called = true + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, "emit-env", lifted.Name) + assert.Nil(t, lifted.Feature) + require.NotNil(t, lifted.Mutate) + + ds := newSingleContainerDaemonSet() + m := NewMutator(ds) + require.NoError(t, lifted.Mutate(m)) + require.NoError(t, m.Apply()) + + assert.True(t, called) + require.Len(t, ds.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", ds.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateEnabledApplies(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(true), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + conv := feature.Mutation[*Mutator](lifted) + + ds := newSingleContainerDaemonSet() + m := NewMutator(ds) + require.NoError(t, conv.ApplyIntent(m)) + require.NoError(t, m.Apply()) + + require.Len(t, ds.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", ds.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(false), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, stubGate(false), lifted.Feature) + conv := feature.Mutation[*Mutator](lifted) + + ds := newSingleContainerDaemonSet() + m := NewMutator(ds) + require.NoError(t, conv.ApplyIntent(m)) + require.NoError(t, m.Apply()) + + assert.Empty(t, ds.Spec.Template.Spec.Containers[0].Env) +} + +func TestLiftMutation_NilMutatePreserved(t *testing.T) { + lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) + assert.Nil(t, lifted.Mutate) + conv := feature.Mutation[*Mutator](lifted) + + err := conv.ApplyIntent(NewMutator(newSingleContainerDaemonSet())) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") +} From 06895d2086ccf7fa89ff0ea972eb2e6e09d918c1 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 20:58:27 +0200 Subject: [PATCH 06/12] test(primitives): one WorkloadMutator emitter across two workload kinds Co-Authored-By: Claude Sonnet 4.6 --- pkg/primitives/workload_mutator_test.go | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 pkg/primitives/workload_mutator_test.go diff --git a/pkg/primitives/workload_mutator_test.go b/pkg/primitives/workload_mutator_test.go new file mode 100644 index 00000000..e53cbc42 --- /dev/null +++ b/pkg/primitives/workload_mutator_test.go @@ -0,0 +1,53 @@ +package primitives_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/primitives" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// emitShared is a single workload-kind-agnostic emitter used for both kinds. +func emitShared() feature.Mutation[primitives.WorkloadMutator] { + return feature.Mutation[primitives.WorkloadMutator]{ + Name: "shared-env", + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } +} + +func TestWorkloadMutator_OneEmitterTwoKinds(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}, + }, + }, + } + sm := statefulset.NewMutator(sts) + require.NoError(t, statefulset.LiftMutation(emitShared()).Mutate(sm)) + require.NoError(t, sm.Apply()) + require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) + + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}, + }, + }, + } + dm := deployment.NewMutator(dep) + require.NoError(t, deployment.LiftMutation(emitShared()).Mutate(dm)) + require.NoError(t, dm.Apply()) + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) +} From d66aa154c2ce3ee5cd00fa3e57795e0071964a7a 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 21:01:18 +0200 Subject: [PATCH 07/12] docs(primitives): document WorkloadMutator and LiftMutation Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/primitives.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/primitives.md b/docs/primitives.md index 92feca66..7cf47f71 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -158,6 +158,35 @@ Mutation names must be unique within a resource. `Build` returns an error if two 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. +### Workload-kind-agnostic mutations + +`*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. + +Write the emitter once against the interface, then lift it into each kind's `Mutation` with that package's +`LiftMutation` adapter before registering it: + +```go +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 + }, + } +} + +zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) +gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) +``` + +`LiftMutation` carries the mutation's `Name` and `Feature` through unchanged. The interface deliberately omits +kind-specific operations (the spec editors, `EnsureReplicas`, and the StatefulSet-only VolumeClaimTemplate methods); +reach for the concrete mutator type when you need those. + ## Mutation Editors Editors provide scoped, typed APIs for modifying specific parts of a resource. Every editor exposes a `.Raw()` method From 25dc8c060c4b824e8da71fbc9a731bc8fe519b21 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 21:02:13 +0200 Subject: [PATCH 08/12] docs: add implementation plan and reflow design spec Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-workload-mutator-interface.md | 665 ++++++++++++++++++ ...06-01-workload-mutator-interface-design.md | 88 +-- 2 files changed, 702 insertions(+), 51 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-01-workload-mutator-interface.md diff --git a/docs/superpowers/plans/2026-06-01-workload-mutator-interface.md b/docs/superpowers/plans/2026-06-01-workload-mutator-interface.md new file mode 100644 index 00000000..05348d42 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-workload-mutator-interface.md @@ -0,0 +1,665 @@ +# Workload-kind-agnostic Mutator Interface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Export a framework interface `primitives.WorkloadMutator` and per-kind `LiftMutation` adapters so consumers +can write one workload-kind-agnostic mutation and apply it across StatefulSet, Deployment, and DaemonSet. + +**Architecture:** A new top-level `pkg/primitives` package holds the shared editing-surface interface (the intersection +of the three pod-workload mutators). Each primitive package adds a compile-time conformance guard and a `LiftMutation` +adapter that wraps a `feature.Mutation[primitives.WorkloadMutator]` into its own defined `Mutation` type. No +reconciliation behavior changes; this is type plumbing plus drift protection. + +**Tech Stack:** Go generics, Ginkgo is used elsewhere but these packages use plain `testing` + testify +(`assert`/`require`), matching the existing `pkg/primitives/*/mutator_test.go` style. + +--- + +### Task 1: Define `primitives.WorkloadMutator` and conformance guards + +**Files:** + +- Create: `pkg/primitives/workload_mutator.go` +- Modify: `pkg/primitives/statefulset/mutator.go` (add guard after the `Mutation` type, line 13 area) +- Modify: `pkg/primitives/deployment/mutator.go` (add guard after the `Mutation` type, line 13 area) +- Modify: `pkg/primitives/daemonset/mutator.go` (add guard after the `Mutation` type, line 13 area) + +- [ ] **Step 1: Create the interface** + +`pkg/primitives/workload_mutator.go`: + +```go +// Package primitives hosts cross-kind contracts shared by the concrete primitive +// packages under pkg/primitives. It depends only on the mutation editor and +// selector packages, never on its own subpackages, so the subpackages can import +// it without creating an import cycle. +package primitives + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + corev1 "k8s.io/api/core/v1" +) + +// WorkloadMutator is the editing surface shared by every pod-workload mutator: +// *statefulset.Mutator, *deployment.Mutator, and *daemonset.Mutator. It lets a +// consumer express one workload-kind-agnostic mutation (for example, emitting a +// shared set of environment variables on the application container) and apply it +// to any of those kinds through the per-package LiftMutation adapters. +// +// It is exactly the intersection of the three concrete mutators' editing methods. +// Kind-specific operations are intentionally excluded and remain on the concrete +// types: the spec editors (EditStatefulSetSpec, EditDeploymentSpec, +// EditDaemonSetSpec), EnsureReplicas (DaemonSets have no replicas), and the +// StatefulSet-only VolumeClaimTemplate methods. The lifecycle methods Apply and +// NextFeature are also excluded; they are driven by the framework, not by an +// emitter. +type WorkloadMutator interface { + EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) + EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) + EnsureContainer(container corev1.Container) + RemoveContainer(name string) + RemoveContainers(names []string) + EnsureInitContainer(container corev1.Container) + RemoveInitContainer(name string) + RemoveInitContainers(names []string) + EditPodSpec(edit func(*editors.PodSpecEditor) error) + EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) + EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) + EnsureContainerEnvVar(ev corev1.EnvVar) + RemoveContainerEnvVar(name string) + RemoveContainerEnvVars(names []string) + EnsureContainerArg(arg string) + RemoveContainerArg(arg string) + RemoveContainerArgs(args []string) +} +``` + +- [ ] **Step 2: Verify the new package builds** + +Run: `go build ./pkg/primitives/` Expected: success, no output. + +- [ ] **Step 3: Add the conformance guard to each concrete mutator** + +In `pkg/primitives/statefulset/mutator.go`, add the `primitives` import to the import block and insert immediately after +the `type Mutation feature.Mutation[*Mutator]` line: + +```go +// Compile-time guarantee that *Mutator satisfies the shared workload editing +// surface. If a future change renames or removes a shared method, this breaks +// the build here instead of drifting silently in downstream consumers. +var _ primitives.WorkloadMutator = (*Mutator)(nil) +``` + +The import to add (alongside the existing `feature`, `editors`, `selectors` imports): + +```go +"github.com/sourcehawk/operator-component-framework/pkg/primitives" +``` + +Repeat the identical guard line and import in `pkg/primitives/deployment/mutator.go` and +`pkg/primitives/daemonset/mutator.go`. + +- [ ] **Step 4: Verify all three satisfy the interface** + +Run: `go build ./...` Expected: success. This compiles the three `var _ primitives.WorkloadMutator = (*Mutator)(nil)` +guards, proving conformance. (If you temporarily rename a shared method on one mutator, this build fails with a "does +not implement" error, which is the drift protection working.) + +- [ ] **Step 5: Commit** + +```bash +git add pkg/primitives/workload_mutator.go pkg/primitives/statefulset/mutator.go pkg/primitives/deployment/mutator.go pkg/primitives/daemonset/mutator.go +git commit -m "feat(primitives): shared WorkloadMutator interface with conformance guards" +``` + +--- + +### Task 2: Add `statefulset.LiftMutation` with tests + +**Files:** + +- Modify: `pkg/primitives/statefulset/mutator.go` (append the function) +- Test: `pkg/primitives/statefulset/mutator_test.go` (append tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `pkg/primitives/statefulset/mutator_test.go`. Add the `feature` and `primitives` imports to the test file's +import block if not present: + +```go +type stubGate bool + +func (g stubGate) Enabled() (bool, error) { return bool(g), nil } + +func newSingleContainerSTS() *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } +} + +func TestLiftMutation_CarriesAndInvokes(t *testing.T) { + called := false + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "emit-env", + Mutate: func(m primitives.WorkloadMutator) error { + called = true + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, "emit-env", lifted.Name) + assert.Nil(t, lifted.Feature) + require.NotNil(t, lifted.Mutate) + + sts := newSingleContainerSTS() + m := NewMutator(sts) + require.NoError(t, lifted.Mutate(m)) + require.NoError(t, m.Apply()) + + assert.True(t, called) + require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(false), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, stubGate(false), lifted.Feature) + + sts := newSingleContainerSTS() + m := NewMutator(sts) + require.NoError(t, feature.Mutation[*Mutator](lifted).ApplyIntent(m)) + require.NoError(t, m.Apply()) + + assert.Empty(t, sts.Spec.Template.Spec.Containers[0].Env) +} + +func TestLiftMutation_NilMutatePreserved(t *testing.T) { + lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) + assert.Nil(t, lifted.Mutate) + + err := feature.Mutation[*Mutator](lifted).ApplyIntent(NewMutator(newSingleContainerSTS())) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./pkg/primitives/statefulset/ -run TestLiftMutation -v` Expected: FAIL with "undefined: LiftMutation". + +- [ ] **Step 3: Implement `LiftMutation`** + +Append to `pkg/primitives/statefulset/mutator.go` (the `primitives` import was already added in Task 1): + +```go +// LiftMutation adapts a workload-kind-agnostic mutation into a StatefulSet +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged. A nil Mutate is preserved, so ApplyIntent +// still reports it by name rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/primitives/statefulset/ -run TestLiftMutation -v` Expected: PASS for all three tests. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/primitives/statefulset/mutator.go pkg/primitives/statefulset/mutator_test.go +git commit -m "feat(statefulset): LiftMutation adapter for WorkloadMutator" +``` + +--- + +### Task 3: Add `deployment.LiftMutation` with tests + +**Files:** + +- Modify: `pkg/primitives/deployment/mutator.go` (append the function) +- Test: `pkg/primitives/deployment/mutator_test.go` (append tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `pkg/primitives/deployment/mutator_test.go`. Add the `feature` and `primitives` imports to the test file's +import block if not present: + +```go +type stubGate bool + +func (g stubGate) Enabled() (bool, error) { return bool(g), nil } + +func newSingleContainerDeployment() *appsv1.Deployment { + return &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } +} + +func TestLiftMutation_CarriesAndInvokes(t *testing.T) { + called := false + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "emit-env", + Mutate: func(m primitives.WorkloadMutator) error { + called = true + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, "emit-env", lifted.Name) + assert.Nil(t, lifted.Feature) + require.NotNil(t, lifted.Mutate) + + dep := newSingleContainerDeployment() + m := NewMutator(dep) + require.NoError(t, lifted.Mutate(m)) + require.NoError(t, m.Apply()) + + assert.True(t, called) + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(false), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, stubGate(false), lifted.Feature) + + dep := newSingleContainerDeployment() + m := NewMutator(dep) + require.NoError(t, feature.Mutation[*Mutator](lifted).ApplyIntent(m)) + require.NoError(t, m.Apply()) + + assert.Empty(t, dep.Spec.Template.Spec.Containers[0].Env) +} + +func TestLiftMutation_NilMutatePreserved(t *testing.T) { + lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) + assert.Nil(t, lifted.Mutate) + + err := feature.Mutation[*Mutator](lifted).ApplyIntent(NewMutator(newSingleContainerDeployment())) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./pkg/primitives/deployment/ -run TestLiftMutation -v` Expected: FAIL with "undefined: LiftMutation". + +- [ ] **Step 3: Implement `LiftMutation`** + +Append to `pkg/primitives/deployment/mutator.go` (the `primitives` import was already added in Task 1): + +```go +// LiftMutation adapts a workload-kind-agnostic mutation into a Deployment +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged. A nil Mutate is preserved, so ApplyIntent +// still reports it by name rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/primitives/deployment/ -run TestLiftMutation -v` Expected: PASS for all three tests. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/primitives/deployment/mutator.go pkg/primitives/deployment/mutator_test.go +git commit -m "feat(deployment): LiftMutation adapter for WorkloadMutator" +``` + +--- + +### Task 4: Add `daemonset.LiftMutation` with tests + +**Files:** + +- Modify: `pkg/primitives/daemonset/mutator.go` (append the function) +- Test: `pkg/primitives/daemonset/mutator_test.go` (append tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `pkg/primitives/daemonset/mutator_test.go`. Add the `feature` and `primitives` imports to the test file's +import block if not present: + +```go +type stubGate bool + +func (g stubGate) Enabled() (bool, error) { return bool(g), nil } + +func newSingleContainerDaemonSet() *appsv1.DaemonSet { + return &appsv1.DaemonSet{ + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } +} + +func TestLiftMutation_CarriesAndInvokes(t *testing.T) { + called := false + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "emit-env", + Mutate: func(m primitives.WorkloadMutator) error { + called = true + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, "emit-env", lifted.Name) + assert.Nil(t, lifted.Feature) + require.NotNil(t, lifted.Mutate) + + ds := newSingleContainerDaemonSet() + m := NewMutator(ds) + require.NoError(t, lifted.Mutate(m)) + require.NoError(t, m.Apply()) + + assert.True(t, called) + require.Len(t, ds.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", ds.Spec.Template.Spec.Containers[0].Env[0].Name) +} + +func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { + agnostic := feature.Mutation[primitives.WorkloadMutator]{ + Name: "gated", + Feature: stubGate(false), + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } + + lifted := LiftMutation(agnostic) + assert.Equal(t, stubGate(false), lifted.Feature) + + ds := newSingleContainerDaemonSet() + m := NewMutator(ds) + require.NoError(t, feature.Mutation[*Mutator](lifted).ApplyIntent(m)) + require.NoError(t, m.Apply()) + + assert.Empty(t, ds.Spec.Template.Spec.Containers[0].Env) +} + +func TestLiftMutation_NilMutatePreserved(t *testing.T) { + lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) + assert.Nil(t, lifted.Mutate) + + err := feature.Mutation[*Mutator](lifted).ApplyIntent(NewMutator(newSingleContainerDaemonSet())) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./pkg/primitives/daemonset/ -run TestLiftMutation -v` Expected: FAIL with "undefined: LiftMutation". + +- [ ] **Step 3: Implement `LiftMutation`** + +Append to `pkg/primitives/daemonset/mutator.go` (the `primitives` import was already added in Task 1): + +```go +// LiftMutation adapts a workload-kind-agnostic mutation into a DaemonSet +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged. A nil Mutate is preserved, so ApplyIntent +// still reports it by name rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/primitives/daemonset/ -run TestLiftMutation -v` Expected: PASS for all three tests. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/primitives/daemonset/mutator.go pkg/primitives/daemonset/mutator_test.go +git commit -m "feat(daemonset): LiftMutation adapter for WorkloadMutator" +``` + +--- + +### Task 5: Cross-kind behavioral test (the issue's actual scenario) + +**Files:** + +- Test: `pkg/primitives/workload_mutator_test.go` (create, external test package) + +This test imports both `statefulset` and `deployment`, which import `primitives`. Putting it in the external +`primitives_test` package avoids any import cycle and proves one emitter drives two workload kinds. + +- [ ] **Step 1: Write the test** + +`pkg/primitives/workload_mutator_test.go`: + +```go +package primitives_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/primitives" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// emitShared is a single workload-kind-agnostic emitter used for both kinds. +func emitShared() feature.Mutation[primitives.WorkloadMutator] { + return feature.Mutation[primitives.WorkloadMutator]{ + Name: "shared-env", + Mutate: func(m primitives.WorkloadMutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) + return nil + }, + } +} + +func TestWorkloadMutator_OneEmitterTwoKinds(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}, + }, + }, + } + sm := statefulset.NewMutator(sts) + require.NoError(t, statefulset.LiftMutation(emitShared()).Mutate(sm)) + require.NoError(t, sm.Apply()) + require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) + + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}, + }, + }, + } + dm := deployment.NewMutator(dep) + require.NoError(t, deployment.LiftMutation(emitShared()).Mutate(dm)) + require.NoError(t, dm.Apply()) + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./pkg/primitives/ -run TestWorkloadMutator_OneEmitterTwoKinds -v` Expected: PASS. + +- [ ] **Step 3: Run the full primitives suite to confirm no regressions** + +Run: `go test ./pkg/primitives/...` Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add pkg/primitives/workload_mutator_test.go +git commit -m "test(primitives): one WorkloadMutator emitter across two workload kinds" +``` + +--- + +### Task 6: Documentation and final verification + +**Files:** + +- Modify: `docs/primitives.md` (add a subsection on the shared workload editing surface) +- Modify: `CLAUDE.md` (add the new package to the "Source to read" list) +- Check: `examples/` (grep for a dual-kind example) + +- [ ] **Step 1: Add a docs subsection** + +Open `docs/primitives.md`, find the mutation system section (search for "Mutation" / the workload mutator discussion). +Add this subsection at the end of that section: + +````markdown +### Workload-kind-agnostic mutations + +`*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. + +Write the emitter once against the interface, then lift it into each kind's `Mutation` with that package's +`LiftMutation` adapter before registering it: + +```go +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 + }, + } +} + +zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) +gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) +``` + +`LiftMutation` carries the mutation's `Name` and feature `Gate` through unchanged. The interface deliberately omits +kind-specific operations (the spec editors, `EnsureReplicas`, and the StatefulSet-only VolumeClaimTemplate methods); +reach for the concrete mutator type when you need those. +```` + +- [ ] **Step 2: Format the markdown** + +Run: `make fmt-md` Expected: success; `docs/primitives.md` reformatted consistently. + +- [ ] **Step 3: Add the package to CLAUDE.md** + +In `CLAUDE.md`, under "### Source to read", add this entry after the `pkg/primitives/` line: + +```markdown +- `pkg/primitives/` (top-level package) — `WorkloadMutator`, the editing surface shared by the pod-workload mutators, + plus the per-kind `LiftMutation` adapters +``` + +- [ ] **Step 4: Check examples for a dual-kind usage** + +Run: `grep -rln "deployment.NewBuilder\|statefulset.NewBuilder" examples/` Expected: a list of example files. If any +single example builds both a Deployment and a StatefulSet from a shared concern, add a short `LiftMutation` usage there +mirroring the docs snippet. If none does, no example change is needed; the docs snippet is sufficient. Either way, do +not invent a new example directory. + +- [ ] **Step 5: Build examples** + +Run: `make build-examples` Expected: success (confirms any example edit compiles; a no-op if none was made). + +- [ ] **Step 6: Full verification** + +Run: `make all` Expected: fmt, lint, and `go test ./...` all green. + +- [ ] **Step 7: Commit** + +```bash +git add docs/primitives.md CLAUDE.md examples/ +git commit -m "docs(primitives): document WorkloadMutator and LiftMutation" +``` + +--- + +## Notes for the implementer + +- The three `LiftMutation` bodies are identical except for the package's own `Mutator`/`Mutation` types. This is not + duplication to factor out: each returns its own package's defined `Mutation` type, which is exactly what that + package's `WithMutation` accepts. A single generic helper would force consumers to convert to the defined type at the + call site, which is the boilerplate this feature removes. +- `Mutation` in each primitive package is a defined type (`type Mutation feature.Mutation[*Mutator]`), not an alias. + That is why the gating tests convert with `feature.Mutation[*Mutator](lifted)` before calling `ApplyIntent` (the + method lives on `feature.Mutation`, and defined types do not inherit it). +- `stubGate` is declared once per test file. If a primitive test file already declares a gate stub, reuse it instead of + redeclaring (a duplicate declaration in the same package will not compile). +- The conformance guards are the reason the `primitives` import lands in each `mutator.go` in Task 1; Tasks 2 to 4 reuse + that import for `LiftMutation` and add no new import there. + +``` + +``` diff --git a/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md b/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md index 8f4bf31e..10b01a87 100644 --- a/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md +++ b/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md @@ -4,32 +4,25 @@ Issue: https://github.com/sourcehawk/operator-component-framework/issues/142 ## Problem -`*statefulset.Mutator`, `*deployment.Mutator`, and `*daemonset.Mutator` expose an -almost identical env/container/podspec/metadata editing surface, but they are -unrelated concrete types. The only framework interface spanning them, -`generic.FeatureMutator`, covers just `Apply()` and `NextFeature()`, not the -editing methods. - -A consumer that wants one workload-kind-agnostic mutation (for example, a shared -"emit these auth/license/storage env vars on the app container" helper rendered as -a StatefulSet by one component and a Deployment by others) cannot express it -against a framework type. The helper must either be duplicated per workload kind or -routed through a consumer-defined structural interface that mirrors the framework's -method set by hand and silently drifts when the framework changes. +`*statefulset.Mutator`, `*deployment.Mutator`, and `*daemonset.Mutator` expose an almost identical +env/container/podspec/metadata editing surface, but they are unrelated concrete types. The only framework interface +spanning them, `generic.FeatureMutator`, covers just `Apply()` and `NextFeature()`, not the editing methods. + +A consumer that wants one workload-kind-agnostic mutation (for example, a shared "emit these auth/license/storage env +vars on the app container" helper rendered as a StatefulSet by one component and a Deployment by others) cannot express +it against a framework type. The helper must either be duplicated per workload kind or routed through a consumer-defined +structural interface that mirrors the framework's method set by hand and silently drifts when the framework changes. ## Solution -Export a framework interface, `primitives.WorkloadMutator`, carrying the shared -editing surface, and a small per-kind adapter that lifts a -`feature.Mutation[primitives.WorkloadMutator]` into each kind's `Mutation` type. +Export a framework interface, `primitives.WorkloadMutator`, carrying the shared editing surface, and a small per-kind +adapter that lifts a `feature.Mutation[primitives.WorkloadMutator]` into each kind's `Mutation` type. ### 1. New package `pkg/primitives` (`package primitives`) -A new top-level package holding the interface. It imports only -`mutation/editors`, `mutation/selectors`, and `corev1`. It never imports its own -subpackages, so the existing `statefulset -> primitives`, -`deployment -> primitives`, and `daemonset -> primitives` import direction stays -acyclic. +A new top-level package holding the interface. It imports only `mutation/editors`, `mutation/selectors`, and `corev1`. +It never imports its own subpackages, so the existing `statefulset -> primitives`, `deployment -> primitives`, and +`daemonset -> primitives` import direction stays acyclic. ```go // WorkloadMutator is the editing surface shared by every pod-workload mutator @@ -58,16 +51,14 @@ type WorkloadMutator interface { Deliberately excluded: -- `Apply()` / `NextFeature()`: framework lifecycle, not an emitter's concern. The - interface stays a pure editing contract. -- `EditStatefulSetSpec` / `EditDeploymentSpec` / `EditDaemonSetSpec`: kind-specific - editor return types. +- `Apply()` / `NextFeature()`: framework lifecycle, not an emitter's concern. The interface stays a pure editing + contract. +- `EditStatefulSetSpec` / `EditDeploymentSpec` / `EditDaemonSetSpec`: kind-specific editor return types. - `EnsureReplicas`: absent on the daemonset mutator (DaemonSets have no replicas). - `EnsureVolumeClaimTemplate` / `RemoveVolumeClaimTemplate`: StatefulSet only. -The result is exactly the intersection across all three existing pod-workload -kinds. A future replica-less kind (for example, a Job-backed workload) can join -without changing the contract. +The result is exactly the intersection across all three existing pod-workload kinds. A future replica-less kind (for +example, a Job-backed workload) can join without changing the contract. ### 2. Compile-time conformance guards @@ -77,9 +68,8 @@ In each of the three primitive packages: var _ primitives.WorkloadMutator = (*Mutator)(nil) ``` -These live in the child packages, not the parent (the parent importing children -would cycle). They are the key advantage over a consumer-maintained mirror: a -future rename or removal of a shared method breaks the build here, inside the +These live in the child packages, not the parent (the parent importing children would cycle). They are the key advantage +over a consumer-maintained mirror: a future rename or removal of a shared method breaks the build here, inside the framework, instead of drifting silently in a downstream operator. ### 3. Per-kind `LiftMutation` adapters @@ -99,8 +89,8 @@ func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { } ``` -`Mutation` is the package's defined type `type Mutation feature.Mutation[*Mutator]`, -which is exactly what each builder's `WithMutation` accepts. +`Mutation` is the package's defined type `type Mutation feature.Mutation[*Mutator]`, which is exactly what each +builder's `WithMutation` accepts. Call site for the issue's scenario: @@ -116,19 +106,17 @@ gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) ### Per-kind `LiftMutation` tests (statefulset, deployment, daemonset) - Name and Feature carry over unchanged. -- The lifted `Mutate` invokes the original with the concrete `*Mutator`, asserted - through an edit that lands on the object after `Apply()`. -- Gating is respected: a disabled `feature.Gate` makes the lifted mutation a no-op - through `ApplyIntent`. -- A nil `Mutate` is preserved (the lifted `Mutate` stays nil, so `ApplyIntent` - reports `mutation handler of is nil` rather than panicking). +- The lifted `Mutate` invokes the original with the concrete `*Mutator`, asserted through an edit that lands on the + object after `Apply()`. +- Gating is respected: a disabled `feature.Gate` makes the lifted mutation a no-op through `ApplyIntent`. +- A nil `Mutate` is preserved (the lifted `Mutate` stays nil, so `ApplyIntent` reports + `mutation handler of is nil` rather than panicking). ### Cross-kind behavioral test -A single `func() feature.Mutation[primitives.WorkloadMutator]` emitter lifted into -both a StatefulSet and a Deployment, applied, asserting the same env var lands on -the app container of both. This guards the feature's actual intent: one emitter, -two workload kinds. +A single `func() feature.Mutation[primitives.WorkloadMutator]` emitter lifted into both a StatefulSet and a Deployment, +applied, asserting the same env var lands on the app container of both. This guards the feature's actual intent: one +emitter, two workload kinds. ### Conformance @@ -138,17 +126,15 @@ The compile-time `var _` guards are the conformance check and need no runtime te Updated in the same change as the code: -- `docs/primitives.md`: a subsection documenting `primitives.WorkloadMutator` and - the `LiftMutation` pattern, with the shared-emitter example. Run `make fmt-md`. -- `CLAUDE.md`: add the new `pkg/primitives` top-level package to the "Source to - read" list. -- `examples/`: grep for a natural spot. If an existing example renders both a - Deployment and a StatefulSet, add a short shared-emitter usage. Otherwise the - `docs/primitives.md` example suffices and no new example directory is added. +- `docs/primitives.md`: a subsection documenting `primitives.WorkloadMutator` and the `LiftMutation` pattern, with the + shared-emitter example. Run `make fmt-md`. +- `CLAUDE.md`: add the new `pkg/primitives` top-level package to the "Source to read" list. +- `examples/`: grep for a natural spot. If an existing example renders both a Deployment and a StatefulSet, add a short + shared-emitter usage. Otherwise the `docs/primitives.md` example suffices and no new example directory is added. Confirm `make build-examples` stays green. -No E2E tests: this is a compile-time and type-plumbing change with no new -reconciliation behavior. Unit and cross-kind behavioral tests cover the intent. +No E2E tests: this is a compile-time and type-plumbing change with no new reconciliation behavior. Unit and cross-kind +behavioral tests cover the intent. ## Verification From bc329326319701ddc903c03ed238efbfac5ff66b 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 21:06:37 +0200 Subject: [PATCH 09/12] refactor(primitives): align LiftMutation placement across workload kinds Move LiftMutation to end-of-file in the deployment and daemonset mutators to match the statefulset layout, so the three adapters read identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/primitives/daemonset/mutator.go | 28 ++++++++++++++-------------- pkg/primitives/deployment/mutator.go | 28 ++++++++++++++-------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/pkg/primitives/daemonset/mutator.go b/pkg/primitives/daemonset/mutator.go index f7d9a87a..51e2fe3d 100644 --- a/pkg/primitives/daemonset/mutator.go +++ b/pkg/primitives/daemonset/mutator.go @@ -436,20 +436,6 @@ func (m *Mutator) Apply() error { return nil } -// LiftMutation adapts a workload-kind-agnostic mutation into a DaemonSet -// Mutation so it can be registered with the builder's WithMutation. Name and -// Feature gating carry over unchanged: when Feature is non-nil and enabled, the -// lifted Mutation behaves identically to one constructed directly against -// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name -// rather than panicking. -func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { - lifted := Mutation{Name: m.Name, Feature: m.Feature} - if m.Mutate != nil { - lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } - } - return lifted -} - func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { found := -1 for i, c := range *containers { @@ -474,3 +460,17 @@ func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { *containers = append(*containers, *op.container) } } + +// LiftMutation adapts a workload-kind-agnostic mutation into a DaemonSet +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged: when Feature is non-nil and enabled, the +// lifted Mutation behaves identically to one constructed directly against +// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name +// rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index 1c2eb5e1..94ba2a54 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -444,20 +444,6 @@ func (m *Mutator) Apply() error { return nil } -// LiftMutation adapts a workload-kind-agnostic mutation into a Deployment -// Mutation so it can be registered with the builder's WithMutation. Name and -// Feature gating carry over unchanged: when Feature is non-nil and enabled, the -// lifted Mutation behaves identically to one constructed directly against -// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name -// rather than panicking. -func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { - lifted := Mutation{Name: m.Name, Feature: m.Feature} - if m.Mutate != nil { - lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } - } - return lifted -} - func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { found := -1 for i, c := range *containers { @@ -482,3 +468,17 @@ func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { *containers = append(*containers, *op.container) } } + +// LiftMutation adapts a workload-kind-agnostic mutation into a Deployment +// Mutation so it can be registered with the builder's WithMutation. Name and +// Feature gating carry over unchanged: when Feature is non-nil and enabled, the +// lifted Mutation behaves identically to one constructed directly against +// *Mutator. A nil Mutate is preserved, so ApplyIntent still reports it by name +// rather than panicking. +func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { + lifted := Mutation{Name: m.Name, Feature: m.Feature} + if m.Mutate != nil { + lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } + } + return lifted +} From fd5f0a84a6dab69774fc9068d583677ebed9d521 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 21:30:31 +0200 Subject: [PATCH 10/12] docs(primitives): close reader gaps in WorkloadMutator section Add the import block (matching the doc's example convention), a DaemonSet lift call, an explicit statement of what LiftMutation returns and why the lift is needed, and name the excluded spec editors while clarifying that EnsureReplicas is excluded because the DaemonSet mutator has no replica field. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/primitives.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index 7cf47f71..52fd50ba 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -169,6 +169,15 @@ Write the emitter once against the interface, then lift it into each kind's `Mut `LiftMutation` adapter before registering it: ```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" +) + func emitAuthEnv() feature.Mutation[primitives.WorkloadMutator] { return feature.Mutation[primitives.WorkloadMutator]{ Name: "auth-env", @@ -181,11 +190,18 @@ func emitAuthEnv() feature.Mutation[primitives.WorkloadMutator] { zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) +nodeAgentDs.WithMutation(daemonset.LiftMutation(emitAuthEnv())) ``` -`LiftMutation` carries the mutation's `Name` and `Feature` through unchanged. The interface deliberately omits -kind-specific operations (the spec editors, `EnsureReplicas`, and the StatefulSet-only VolumeClaimTemplate methods); -reach for the concrete mutator type when you need those. +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. + +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. ## Mutation Editors From 821cdb01c5a6b61b8a8c2f0e66a8d114545178ae 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:01:40 +0200 Subject: [PATCH 11/12] docs(primitives): scope WorkloadMutator doc to the three named kinds Name StatefulSet/Deployment/DaemonSet as the explicit set the interface spans, rather than implying it covers every pod-based primitive (pod, job, cronjob, replicaset also expose pod-template editing methods). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-workload-mutator-interface.md | 665 ------------------ ...06-01-workload-mutator-interface-design.md | 142 ---- pkg/primitives/workload_mutator.go | 19 +- 3 files changed, 10 insertions(+), 816 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-01-workload-mutator-interface.md delete mode 100644 docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md diff --git a/docs/superpowers/plans/2026-06-01-workload-mutator-interface.md b/docs/superpowers/plans/2026-06-01-workload-mutator-interface.md deleted file mode 100644 index 05348d42..00000000 --- a/docs/superpowers/plans/2026-06-01-workload-mutator-interface.md +++ /dev/null @@ -1,665 +0,0 @@ -# Workload-kind-agnostic Mutator Interface Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Export a framework interface `primitives.WorkloadMutator` and per-kind `LiftMutation` adapters so consumers -can write one workload-kind-agnostic mutation and apply it across StatefulSet, Deployment, and DaemonSet. - -**Architecture:** A new top-level `pkg/primitives` package holds the shared editing-surface interface (the intersection -of the three pod-workload mutators). Each primitive package adds a compile-time conformance guard and a `LiftMutation` -adapter that wraps a `feature.Mutation[primitives.WorkloadMutator]` into its own defined `Mutation` type. No -reconciliation behavior changes; this is type plumbing plus drift protection. - -**Tech Stack:** Go generics, Ginkgo is used elsewhere but these packages use plain `testing` + testify -(`assert`/`require`), matching the existing `pkg/primitives/*/mutator_test.go` style. - ---- - -### Task 1: Define `primitives.WorkloadMutator` and conformance guards - -**Files:** - -- Create: `pkg/primitives/workload_mutator.go` -- Modify: `pkg/primitives/statefulset/mutator.go` (add guard after the `Mutation` type, line 13 area) -- Modify: `pkg/primitives/deployment/mutator.go` (add guard after the `Mutation` type, line 13 area) -- Modify: `pkg/primitives/daemonset/mutator.go` (add guard after the `Mutation` type, line 13 area) - -- [ ] **Step 1: Create the interface** - -`pkg/primitives/workload_mutator.go`: - -```go -// Package primitives hosts cross-kind contracts shared by the concrete primitive -// packages under pkg/primitives. It depends only on the mutation editor and -// selector packages, never on its own subpackages, so the subpackages can import -// it without creating an import cycle. -package primitives - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - corev1 "k8s.io/api/core/v1" -) - -// WorkloadMutator is the editing surface shared by every pod-workload mutator: -// *statefulset.Mutator, *deployment.Mutator, and *daemonset.Mutator. It lets a -// consumer express one workload-kind-agnostic mutation (for example, emitting a -// shared set of environment variables on the application container) and apply it -// to any of those kinds through the per-package LiftMutation adapters. -// -// It is exactly the intersection of the three concrete mutators' editing methods. -// Kind-specific operations are intentionally excluded and remain on the concrete -// types: the spec editors (EditStatefulSetSpec, EditDeploymentSpec, -// EditDaemonSetSpec), EnsureReplicas (DaemonSets have no replicas), and the -// StatefulSet-only VolumeClaimTemplate methods. The lifecycle methods Apply and -// NextFeature are also excluded; they are driven by the framework, not by an -// emitter. -type WorkloadMutator interface { - EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) - EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) - EnsureContainer(container corev1.Container) - RemoveContainer(name string) - RemoveContainers(names []string) - EnsureInitContainer(container corev1.Container) - RemoveInitContainer(name string) - RemoveInitContainers(names []string) - EditPodSpec(edit func(*editors.PodSpecEditor) error) - EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) - EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) - EnsureContainerEnvVar(ev corev1.EnvVar) - RemoveContainerEnvVar(name string) - RemoveContainerEnvVars(names []string) - EnsureContainerArg(arg string) - RemoveContainerArg(arg string) - RemoveContainerArgs(args []string) -} -``` - -- [ ] **Step 2: Verify the new package builds** - -Run: `go build ./pkg/primitives/` Expected: success, no output. - -- [ ] **Step 3: Add the conformance guard to each concrete mutator** - -In `pkg/primitives/statefulset/mutator.go`, add the `primitives` import to the import block and insert immediately after -the `type Mutation feature.Mutation[*Mutator]` line: - -```go -// Compile-time guarantee that *Mutator satisfies the shared workload editing -// surface. If a future change renames or removes a shared method, this breaks -// the build here instead of drifting silently in downstream consumers. -var _ primitives.WorkloadMutator = (*Mutator)(nil) -``` - -The import to add (alongside the existing `feature`, `editors`, `selectors` imports): - -```go -"github.com/sourcehawk/operator-component-framework/pkg/primitives" -``` - -Repeat the identical guard line and import in `pkg/primitives/deployment/mutator.go` and -`pkg/primitives/daemonset/mutator.go`. - -- [ ] **Step 4: Verify all three satisfy the interface** - -Run: `go build ./...` Expected: success. This compiles the three `var _ primitives.WorkloadMutator = (*Mutator)(nil)` -guards, proving conformance. (If you temporarily rename a shared method on one mutator, this build fails with a "does -not implement" error, which is the drift protection working.) - -- [ ] **Step 5: Commit** - -```bash -git add pkg/primitives/workload_mutator.go pkg/primitives/statefulset/mutator.go pkg/primitives/deployment/mutator.go pkg/primitives/daemonset/mutator.go -git commit -m "feat(primitives): shared WorkloadMutator interface with conformance guards" -``` - ---- - -### Task 2: Add `statefulset.LiftMutation` with tests - -**Files:** - -- Modify: `pkg/primitives/statefulset/mutator.go` (append the function) -- Test: `pkg/primitives/statefulset/mutator_test.go` (append tests) - -- [ ] **Step 1: Write the failing tests** - -Append to `pkg/primitives/statefulset/mutator_test.go`. Add the `feature` and `primitives` imports to the test file's -import block if not present: - -```go -type stubGate bool - -func (g stubGate) Enabled() (bool, error) { return bool(g), nil } - -func newSingleContainerSTS() *appsv1.StatefulSet { - return &appsv1.StatefulSet{ - Spec: appsv1.StatefulSetSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "main"}}, - }, - }, - }, - } -} - -func TestLiftMutation_CarriesAndInvokes(t *testing.T) { - called := false - agnostic := feature.Mutation[primitives.WorkloadMutator]{ - Name: "emit-env", - Mutate: func(m primitives.WorkloadMutator) error { - called = true - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } - - lifted := LiftMutation(agnostic) - assert.Equal(t, "emit-env", lifted.Name) - assert.Nil(t, lifted.Feature) - require.NotNil(t, lifted.Mutate) - - sts := newSingleContainerSTS() - m := NewMutator(sts) - require.NoError(t, lifted.Mutate(m)) - require.NoError(t, m.Apply()) - - assert.True(t, called) - require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) - assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) -} - -func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { - agnostic := feature.Mutation[primitives.WorkloadMutator]{ - Name: "gated", - Feature: stubGate(false), - Mutate: func(m primitives.WorkloadMutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } - - lifted := LiftMutation(agnostic) - assert.Equal(t, stubGate(false), lifted.Feature) - - sts := newSingleContainerSTS() - m := NewMutator(sts) - require.NoError(t, feature.Mutation[*Mutator](lifted).ApplyIntent(m)) - require.NoError(t, m.Apply()) - - assert.Empty(t, sts.Spec.Template.Spec.Containers[0].Env) -} - -func TestLiftMutation_NilMutatePreserved(t *testing.T) { - lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) - assert.Nil(t, lifted.Mutate) - - err := feature.Mutation[*Mutator](lifted).ApplyIntent(NewMutator(newSingleContainerSTS())) - require.Error(t, err) - assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./pkg/primitives/statefulset/ -run TestLiftMutation -v` Expected: FAIL with "undefined: LiftMutation". - -- [ ] **Step 3: Implement `LiftMutation`** - -Append to `pkg/primitives/statefulset/mutator.go` (the `primitives` import was already added in Task 1): - -```go -// LiftMutation adapts a workload-kind-agnostic mutation into a StatefulSet -// Mutation so it can be registered with the builder's WithMutation. Name and -// Feature gating carry over unchanged. A nil Mutate is preserved, so ApplyIntent -// still reports it by name rather than panicking. -func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { - lifted := Mutation{Name: m.Name, Feature: m.Feature} - if m.Mutate != nil { - lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } - } - return lifted -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./pkg/primitives/statefulset/ -run TestLiftMutation -v` Expected: PASS for all three tests. - -- [ ] **Step 5: Commit** - -```bash -git add pkg/primitives/statefulset/mutator.go pkg/primitives/statefulset/mutator_test.go -git commit -m "feat(statefulset): LiftMutation adapter for WorkloadMutator" -``` - ---- - -### Task 3: Add `deployment.LiftMutation` with tests - -**Files:** - -- Modify: `pkg/primitives/deployment/mutator.go` (append the function) -- Test: `pkg/primitives/deployment/mutator_test.go` (append tests) - -- [ ] **Step 1: Write the failing tests** - -Append to `pkg/primitives/deployment/mutator_test.go`. Add the `feature` and `primitives` imports to the test file's -import block if not present: - -```go -type stubGate bool - -func (g stubGate) Enabled() (bool, error) { return bool(g), nil } - -func newSingleContainerDeployment() *appsv1.Deployment { - return &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "main"}}, - }, - }, - }, - } -} - -func TestLiftMutation_CarriesAndInvokes(t *testing.T) { - called := false - agnostic := feature.Mutation[primitives.WorkloadMutator]{ - Name: "emit-env", - Mutate: func(m primitives.WorkloadMutator) error { - called = true - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } - - lifted := LiftMutation(agnostic) - assert.Equal(t, "emit-env", lifted.Name) - assert.Nil(t, lifted.Feature) - require.NotNil(t, lifted.Mutate) - - dep := newSingleContainerDeployment() - m := NewMutator(dep) - require.NoError(t, lifted.Mutate(m)) - require.NoError(t, m.Apply()) - - assert.True(t, called) - require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) - assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) -} - -func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { - agnostic := feature.Mutation[primitives.WorkloadMutator]{ - Name: "gated", - Feature: stubGate(false), - Mutate: func(m primitives.WorkloadMutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } - - lifted := LiftMutation(agnostic) - assert.Equal(t, stubGate(false), lifted.Feature) - - dep := newSingleContainerDeployment() - m := NewMutator(dep) - require.NoError(t, feature.Mutation[*Mutator](lifted).ApplyIntent(m)) - require.NoError(t, m.Apply()) - - assert.Empty(t, dep.Spec.Template.Spec.Containers[0].Env) -} - -func TestLiftMutation_NilMutatePreserved(t *testing.T) { - lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) - assert.Nil(t, lifted.Mutate) - - err := feature.Mutation[*Mutator](lifted).ApplyIntent(NewMutator(newSingleContainerDeployment())) - require.Error(t, err) - assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./pkg/primitives/deployment/ -run TestLiftMutation -v` Expected: FAIL with "undefined: LiftMutation". - -- [ ] **Step 3: Implement `LiftMutation`** - -Append to `pkg/primitives/deployment/mutator.go` (the `primitives` import was already added in Task 1): - -```go -// LiftMutation adapts a workload-kind-agnostic mutation into a Deployment -// Mutation so it can be registered with the builder's WithMutation. Name and -// Feature gating carry over unchanged. A nil Mutate is preserved, so ApplyIntent -// still reports it by name rather than panicking. -func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { - lifted := Mutation{Name: m.Name, Feature: m.Feature} - if m.Mutate != nil { - lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } - } - return lifted -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./pkg/primitives/deployment/ -run TestLiftMutation -v` Expected: PASS for all three tests. - -- [ ] **Step 5: Commit** - -```bash -git add pkg/primitives/deployment/mutator.go pkg/primitives/deployment/mutator_test.go -git commit -m "feat(deployment): LiftMutation adapter for WorkloadMutator" -``` - ---- - -### Task 4: Add `daemonset.LiftMutation` with tests - -**Files:** - -- Modify: `pkg/primitives/daemonset/mutator.go` (append the function) -- Test: `pkg/primitives/daemonset/mutator_test.go` (append tests) - -- [ ] **Step 1: Write the failing tests** - -Append to `pkg/primitives/daemonset/mutator_test.go`. Add the `feature` and `primitives` imports to the test file's -import block if not present: - -```go -type stubGate bool - -func (g stubGate) Enabled() (bool, error) { return bool(g), nil } - -func newSingleContainerDaemonSet() *appsv1.DaemonSet { - return &appsv1.DaemonSet{ - Spec: appsv1.DaemonSetSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "main"}}, - }, - }, - }, - } -} - -func TestLiftMutation_CarriesAndInvokes(t *testing.T) { - called := false - agnostic := feature.Mutation[primitives.WorkloadMutator]{ - Name: "emit-env", - Mutate: func(m primitives.WorkloadMutator) error { - called = true - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } - - lifted := LiftMutation(agnostic) - assert.Equal(t, "emit-env", lifted.Name) - assert.Nil(t, lifted.Feature) - require.NotNil(t, lifted.Mutate) - - ds := newSingleContainerDaemonSet() - m := NewMutator(ds) - require.NoError(t, lifted.Mutate(m)) - require.NoError(t, m.Apply()) - - assert.True(t, called) - require.Len(t, ds.Spec.Template.Spec.Containers[0].Env, 1) - assert.Equal(t, "SHARED", ds.Spec.Template.Spec.Containers[0].Env[0].Name) -} - -func TestLiftMutation_GateDisabledIsNoOp(t *testing.T) { - agnostic := feature.Mutation[primitives.WorkloadMutator]{ - Name: "gated", - Feature: stubGate(false), - Mutate: func(m primitives.WorkloadMutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } - - lifted := LiftMutation(agnostic) - assert.Equal(t, stubGate(false), lifted.Feature) - - ds := newSingleContainerDaemonSet() - m := NewMutator(ds) - require.NoError(t, feature.Mutation[*Mutator](lifted).ApplyIntent(m)) - require.NoError(t, m.Apply()) - - assert.Empty(t, ds.Spec.Template.Spec.Containers[0].Env) -} - -func TestLiftMutation_NilMutatePreserved(t *testing.T) { - lifted := LiftMutation(feature.Mutation[primitives.WorkloadMutator]{Name: "nilmut"}) - assert.Nil(t, lifted.Mutate) - - err := feature.Mutation[*Mutator](lifted).ApplyIntent(NewMutator(newSingleContainerDaemonSet())) - require.Error(t, err) - assert.Contains(t, err.Error(), "mutation handler of nilmut is nil") -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./pkg/primitives/daemonset/ -run TestLiftMutation -v` Expected: FAIL with "undefined: LiftMutation". - -- [ ] **Step 3: Implement `LiftMutation`** - -Append to `pkg/primitives/daemonset/mutator.go` (the `primitives` import was already added in Task 1): - -```go -// LiftMutation adapts a workload-kind-agnostic mutation into a DaemonSet -// Mutation so it can be registered with the builder's WithMutation. Name and -// Feature gating carry over unchanged. A nil Mutate is preserved, so ApplyIntent -// still reports it by name rather than panicking. -func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { - lifted := Mutation{Name: m.Name, Feature: m.Feature} - if m.Mutate != nil { - lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } - } - return lifted -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./pkg/primitives/daemonset/ -run TestLiftMutation -v` Expected: PASS for all three tests. - -- [ ] **Step 5: Commit** - -```bash -git add pkg/primitives/daemonset/mutator.go pkg/primitives/daemonset/mutator_test.go -git commit -m "feat(daemonset): LiftMutation adapter for WorkloadMutator" -``` - ---- - -### Task 5: Cross-kind behavioral test (the issue's actual scenario) - -**Files:** - -- Test: `pkg/primitives/workload_mutator_test.go` (create, external test package) - -This test imports both `statefulset` and `deployment`, which import `primitives`. Putting it in the external -`primitives_test` package avoids any import cycle and proves one emitter drives two workload kinds. - -- [ ] **Step 1: Write the test** - -`pkg/primitives/workload_mutator_test.go`: - -```go -package primitives_test - -import ( - "testing" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/primitives" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" -) - -// emitShared is a single workload-kind-agnostic emitter used for both kinds. -func emitShared() feature.Mutation[primitives.WorkloadMutator] { - return feature.Mutation[primitives.WorkloadMutator]{ - Name: "shared-env", - Mutate: func(m primitives.WorkloadMutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHARED", Value: "x"}) - return nil - }, - } -} - -func TestWorkloadMutator_OneEmitterTwoKinds(t *testing.T) { - sts := &appsv1.StatefulSet{ - Spec: appsv1.StatefulSetSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}, - }, - }, - } - sm := statefulset.NewMutator(sts) - require.NoError(t, statefulset.LiftMutation(emitShared()).Mutate(sm)) - require.NoError(t, sm.Apply()) - require.Len(t, sts.Spec.Template.Spec.Containers[0].Env, 1) - assert.Equal(t, "SHARED", sts.Spec.Template.Spec.Containers[0].Env[0].Name) - - dep := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}, - }, - }, - } - dm := deployment.NewMutator(dep) - require.NoError(t, deployment.LiftMutation(emitShared()).Mutate(dm)) - require.NoError(t, dm.Apply()) - require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) - assert.Equal(t, "SHARED", dep.Spec.Template.Spec.Containers[0].Env[0].Name) -} -``` - -- [ ] **Step 2: Run the test** - -Run: `go test ./pkg/primitives/ -run TestWorkloadMutator_OneEmitterTwoKinds -v` Expected: PASS. - -- [ ] **Step 3: Run the full primitives suite to confirm no regressions** - -Run: `go test ./pkg/primitives/...` Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add pkg/primitives/workload_mutator_test.go -git commit -m "test(primitives): one WorkloadMutator emitter across two workload kinds" -``` - ---- - -### Task 6: Documentation and final verification - -**Files:** - -- Modify: `docs/primitives.md` (add a subsection on the shared workload editing surface) -- Modify: `CLAUDE.md` (add the new package to the "Source to read" list) -- Check: `examples/` (grep for a dual-kind example) - -- [ ] **Step 1: Add a docs subsection** - -Open `docs/primitives.md`, find the mutation system section (search for "Mutation" / the workload mutator discussion). -Add this subsection at the end of that section: - -````markdown -### Workload-kind-agnostic mutations - -`*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. - -Write the emitter once against the interface, then lift it into each kind's `Mutation` with that package's -`LiftMutation` adapter before registering it: - -```go -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 - }, - } -} - -zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) -gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) -``` - -`LiftMutation` carries the mutation's `Name` and feature `Gate` through unchanged. The interface deliberately omits -kind-specific operations (the spec editors, `EnsureReplicas`, and the StatefulSet-only VolumeClaimTemplate methods); -reach for the concrete mutator type when you need those. -```` - -- [ ] **Step 2: Format the markdown** - -Run: `make fmt-md` Expected: success; `docs/primitives.md` reformatted consistently. - -- [ ] **Step 3: Add the package to CLAUDE.md** - -In `CLAUDE.md`, under "### Source to read", add this entry after the `pkg/primitives/` line: - -```markdown -- `pkg/primitives/` (top-level package) — `WorkloadMutator`, the editing surface shared by the pod-workload mutators, - plus the per-kind `LiftMutation` adapters -``` - -- [ ] **Step 4: Check examples for a dual-kind usage** - -Run: `grep -rln "deployment.NewBuilder\|statefulset.NewBuilder" examples/` Expected: a list of example files. If any -single example builds both a Deployment and a StatefulSet from a shared concern, add a short `LiftMutation` usage there -mirroring the docs snippet. If none does, no example change is needed; the docs snippet is sufficient. Either way, do -not invent a new example directory. - -- [ ] **Step 5: Build examples** - -Run: `make build-examples` Expected: success (confirms any example edit compiles; a no-op if none was made). - -- [ ] **Step 6: Full verification** - -Run: `make all` Expected: fmt, lint, and `go test ./...` all green. - -- [ ] **Step 7: Commit** - -```bash -git add docs/primitives.md CLAUDE.md examples/ -git commit -m "docs(primitives): document WorkloadMutator and LiftMutation" -``` - ---- - -## Notes for the implementer - -- The three `LiftMutation` bodies are identical except for the package's own `Mutator`/`Mutation` types. This is not - duplication to factor out: each returns its own package's defined `Mutation` type, which is exactly what that - package's `WithMutation` accepts. A single generic helper would force consumers to convert to the defined type at the - call site, which is the boilerplate this feature removes. -- `Mutation` in each primitive package is a defined type (`type Mutation feature.Mutation[*Mutator]`), not an alias. - That is why the gating tests convert with `feature.Mutation[*Mutator](lifted)` before calling `ApplyIntent` (the - method lives on `feature.Mutation`, and defined types do not inherit it). -- `stubGate` is declared once per test file. If a primitive test file already declares a gate stub, reuse it instead of - redeclaring (a duplicate declaration in the same package will not compile). -- The conformance guards are the reason the `primitives` import lands in each `mutator.go` in Task 1; Tasks 2 to 4 reuse - that import for `LiftMutation` and add no new import there. - -``` - -``` diff --git a/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md b/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md deleted file mode 100644 index 10b01a87..00000000 --- a/docs/superpowers/specs/2026-06-01-workload-mutator-interface-design.md +++ /dev/null @@ -1,142 +0,0 @@ -# Workload-kind-agnostic mutator interface - -Issue: https://github.com/sourcehawk/operator-component-framework/issues/142 - -## Problem - -`*statefulset.Mutator`, `*deployment.Mutator`, and `*daemonset.Mutator` expose an almost identical -env/container/podspec/metadata editing surface, but they are unrelated concrete types. The only framework interface -spanning them, `generic.FeatureMutator`, covers just `Apply()` and `NextFeature()`, not the editing methods. - -A consumer that wants one workload-kind-agnostic mutation (for example, a shared "emit these auth/license/storage env -vars on the app container" helper rendered as a StatefulSet by one component and a Deployment by others) cannot express -it against a framework type. The helper must either be duplicated per workload kind or routed through a consumer-defined -structural interface that mirrors the framework's method set by hand and silently drifts when the framework changes. - -## Solution - -Export a framework interface, `primitives.WorkloadMutator`, carrying the shared editing surface, and a small per-kind -adapter that lifts a `feature.Mutation[primitives.WorkloadMutator]` into each kind's `Mutation` type. - -### 1. New package `pkg/primitives` (`package primitives`) - -A new top-level package holding the interface. It imports only `mutation/editors`, `mutation/selectors`, and `corev1`. -It never imports its own subpackages, so the existing `statefulset -> primitives`, `deployment -> primitives`, and -`daemonset -> primitives` import direction stays acyclic. - -```go -// WorkloadMutator is the editing surface shared by every pod-workload mutator -// (*statefulset.Mutator, *deployment.Mutator, *daemonset.Mutator). It lets a -// consumer write one workload-kind-agnostic mutation and apply it to any of them. -type WorkloadMutator interface { - EditContainers(selectors.ContainerSelector, func(*editors.ContainerEditor) error) - EditInitContainers(selectors.ContainerSelector, func(*editors.ContainerEditor) error) - EnsureContainer(corev1.Container) - RemoveContainer(string) - RemoveContainers([]string) - EnsureInitContainer(corev1.Container) - RemoveInitContainer(string) - RemoveInitContainers([]string) - EditPodSpec(func(*editors.PodSpecEditor) error) - EditPodTemplateMetadata(func(*editors.ObjectMetaEditor) error) - EditObjectMetadata(func(*editors.ObjectMetaEditor) error) - EnsureContainerEnvVar(corev1.EnvVar) - RemoveContainerEnvVar(string) - RemoveContainerEnvVars([]string) - EnsureContainerArg(string) - RemoveContainerArg(string) - RemoveContainerArgs([]string) -} -``` - -Deliberately excluded: - -- `Apply()` / `NextFeature()`: framework lifecycle, not an emitter's concern. The interface stays a pure editing - contract. -- `EditStatefulSetSpec` / `EditDeploymentSpec` / `EditDaemonSetSpec`: kind-specific editor return types. -- `EnsureReplicas`: absent on the daemonset mutator (DaemonSets have no replicas). -- `EnsureVolumeClaimTemplate` / `RemoveVolumeClaimTemplate`: StatefulSet only. - -The result is exactly the intersection across all three existing pod-workload kinds. A future replica-less kind (for -example, a Job-backed workload) can join without changing the contract. - -### 2. Compile-time conformance guards - -In each of the three primitive packages: - -```go -var _ primitives.WorkloadMutator = (*Mutator)(nil) -``` - -These live in the child packages, not the parent (the parent importing children would cycle). They are the key advantage -over a consumer-maintained mirror: a future rename or removal of a shared method breaks the build here, inside the -framework, instead of drifting silently in a downstream operator. - -### 3. Per-kind `LiftMutation` adapters - -In each of `statefulset`, `deployment`, `daemonset`: - -```go -// LiftMutation adapts a workload-kind-agnostic mutation into a Mutation -// so it can be registered with WithMutation. Name and Feature gating carry over -// unchanged. A nil Mutate is preserved so ApplyIntent still reports it by name. -func LiftMutation(m feature.Mutation[primitives.WorkloadMutator]) Mutation { - lifted := Mutation{Name: m.Name, Feature: m.Feature} - if m.Mutate != nil { - lifted.Mutate = func(mut *Mutator) error { return m.Mutate(mut) } - } - return lifted -} -``` - -`Mutation` is the package's defined type `type Mutation feature.Mutation[*Mutator]`, which is exactly what each -builder's `WithMutation` accepts. - -Call site for the issue's scenario: - -```go -func emitAuthEnv() feature.Mutation[primitives.WorkloadMutator] { /* one emitter */ } - -zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv())) -gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv())) -``` - -## Testing - -### Per-kind `LiftMutation` tests (statefulset, deployment, daemonset) - -- Name and Feature carry over unchanged. -- The lifted `Mutate` invokes the original with the concrete `*Mutator`, asserted through an edit that lands on the - object after `Apply()`. -- Gating is respected: a disabled `feature.Gate` makes the lifted mutation a no-op through `ApplyIntent`. -- A nil `Mutate` is preserved (the lifted `Mutate` stays nil, so `ApplyIntent` reports - `mutation handler of is nil` rather than panicking). - -### Cross-kind behavioral test - -A single `func() feature.Mutation[primitives.WorkloadMutator]` emitter lifted into both a StatefulSet and a Deployment, -applied, asserting the same env var lands on the app container of both. This guards the feature's actual intent: one -emitter, two workload kinds. - -### Conformance - -The compile-time `var _` guards are the conformance check and need no runtime test. - -## Documentation and housekeeping - -Updated in the same change as the code: - -- `docs/primitives.md`: a subsection documenting `primitives.WorkloadMutator` and the `LiftMutation` pattern, with the - shared-emitter example. Run `make fmt-md`. -- `CLAUDE.md`: add the new `pkg/primitives` top-level package to the "Source to read" list. -- `examples/`: grep for a natural spot. If an existing example renders both a Deployment and a StatefulSet, add a short - shared-emitter usage. Otherwise the `docs/primitives.md` example suffices and no new example directory is added. - Confirm `make build-examples` stays green. - -No E2E tests: this is a compile-time and type-plumbing change with no new reconciliation behavior. Unit and cross-kind -behavioral tests cover the intent. - -## Verification - -- `make all` (fmt, lint, test) green. -- `make build-examples` green. diff --git a/pkg/primitives/workload_mutator.go b/pkg/primitives/workload_mutator.go index e43985a4..d9cb3b13 100644 --- a/pkg/primitives/workload_mutator.go +++ b/pkg/primitives/workload_mutator.go @@ -10,19 +10,20 @@ import ( corev1 "k8s.io/api/core/v1" ) -// WorkloadMutator is the editing surface shared by every pod-workload mutator: -// *statefulset.Mutator, *deployment.Mutator, and *daemonset.Mutator. It lets a -// consumer express one workload-kind-agnostic mutation (for example, emitting a -// shared set of environment variables on the application container) and apply it -// to any of those kinds through the per-package LiftMutation adapters. +// WorkloadMutator is the editing surface shared by the StatefulSet, Deployment, +// and DaemonSet mutators (*statefulset.Mutator, *deployment.Mutator, +// *daemonset.Mutator). It lets a consumer express one workload-kind-agnostic +// mutation (for example, emitting a shared set of environment variables on the +// application container) and apply it to any of those kinds through the +// per-package LiftMutation adapters. // -// It is exactly the intersection of the three concrete mutators' editing methods. +// It is exactly the intersection of those three mutators' editing methods. // Kind-specific operations are intentionally excluded and remain on the concrete // types: the spec editors (EditStatefulSetSpec, EditDeploymentSpec, // EditDaemonSetSpec), EnsureReplicas (absent from the DaemonSet mutator, which -// has no replica field), and the StatefulSet-only VolumeClaimTemplate methods. The lifecycle methods Apply and -// NextFeature are also excluded; they are driven by the framework, not by an -// emitter. +// has no replica field), and the StatefulSet-only VolumeClaimTemplate methods. +// The lifecycle methods Apply and NextFeature are also excluded; they are driven +// by the framework, not by an emitter. type WorkloadMutator interface { EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) From 2767af565b7d7282a4d2408358531325c651412e 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:01:40 +0200 Subject: [PATCH 12/12] chore: untrack internal planning docs; record new package in AI instructions Remove the design spec and implementation plan from version control (kept local via .gitignore) so internal planning artifacts stay out of the published diff. Add the new pkg/primitives top-level package to the "Source to read" list in the committed instruction files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .ai/base.md | 2 ++ .github/copilot-instructions.md | 2 ++ .gitignore | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.ai/base.md b/.ai/base.md index e73e53ff..3a451665 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -34,6 +34,8 @@ Verify the real API before using or documenting it. Key packages: - `pkg/component/` — builder, reconciliation, condition types, participation modes - `pkg/component/concepts/` — lifecycle interfaces and their exact status type constants - `pkg/primitives/` — kubernetes primitive resource wrappers with builders and mutators +- `pkg/primitives/` (top-level package) — `WorkloadMutator`, the editing surface shared by the pod-workload mutators, + plus the per-kind `LiftMutation` adapters - `pkg/generic/` — generic building blocks for custom resource wrappers (reconciliation, mutation sequencing, suspension, data extraction) - `pkg/mutation/editors/` — available methods per editor type diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0596eabe..4e837b00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,6 +34,8 @@ Verify the real API before using or documenting it. Key packages: - `pkg/component/` — builder, reconciliation, condition types, participation modes - `pkg/component/concepts/` — lifecycle interfaces and their exact status type constants - `pkg/primitives/` — kubernetes primitive resource wrappers with builders and mutators +- `pkg/primitives/` (top-level package) — `WorkloadMutator`, the editing surface shared by the pod-workload mutators, + plus the per-kind `LiftMutation` adapters - `pkg/generic/` — generic building blocks for custom resource wrappers (reconciliation, mutation sequencing, suspension, data extraction) - `pkg/mutation/editors/` — available methods per editor type diff --git a/.gitignore b/.gitignore index 1a329c90..6aed4f58 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ tmp/ !.claude/settings.json .context CLAUDE.md -posts \ No newline at end of file +posts +# Internal planning artifacts (specs/plans), keep local-only +docs/superpowers/