Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .ai/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ tmp/
!.claude/settings.json
.context
CLAUDE.md
posts
posts
# Internal planning artifacts (specs/plans), keep local-only
docs/superpowers/
45 changes: 45 additions & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions pkg/primitives/daemonset/mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down Expand Up @@ -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
}
99 changes: 99 additions & 0 deletions pkg/primitives/daemonset/mutator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,34 @@ 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"
corev1 "k8s.io/api/core/v1"
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{
Expand Down Expand Up @@ -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")
}
20 changes: 20 additions & 0 deletions pkg/primitives/deployment/mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down Expand Up @@ -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
}
99 changes: 99 additions & 0 deletions pkg/primitives/deployment/mutator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
Loading
Loading