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/ diff --git a/docs/primitives.md b/docs/primitives.md index 92feca66..52fd50ba 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -158,6 +158,51 @@ 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 +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", + 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())) +nodeAgentDs.WithMutation(daemonset.LiftMutation(emitAuthEnv())) +``` + +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 Editors provide scoped, typed APIs for modifying specific parts of a resource. Every editor exposes a `.Raw()` method diff --git a/pkg/primitives/daemonset/mutator.go b/pkg/primitives/daemonset/mutator.go index 254e6bef..51e2fe3d 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 @@ -454,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/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") +} diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index 82511bfe..94ba2a54 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 @@ -462,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 +} 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") +} diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index ec89d261..fab28897 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 @@ -516,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") +} diff --git a/pkg/primitives/workload_mutator.go b/pkg/primitives/workload_mutator.go new file mode 100644 index 00000000..d9cb3b13 --- /dev/null +++ b/pkg/primitives/workload_mutator.go @@ -0,0 +1,45 @@ +// 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 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 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. +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) +} 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) +}