From cd854e2b1582261c6713cb3de5186c9deb8b2c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:37:05 +0000 Subject: [PATCH 01/32] Add JobSpecEditor and CronJobSpecEditor mutation editors JobSpecEditor provides typed methods for Job spec fields (completions, parallelism, backoff limit, etc.). CronJobSpecEditor provides typed methods for CronJob spec fields (schedule, concurrency policy, time zone, etc.). The suspend field is intentionally excluded from CronJobSpecEditor as it is managed by the framework's suspension system. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/cronjobspec.go | 56 +++++++++++++++++++++++ pkg/mutation/editors/cronjobspec_test.go | 58 ++++++++++++++++++++++++ pkg/mutation/editors/jobspec.go | 53 ++++++++++++++++++++++ pkg/mutation/editors/jobspec_test.go | 58 ++++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 pkg/mutation/editors/cronjobspec.go create mode 100644 pkg/mutation/editors/cronjobspec_test.go create mode 100644 pkg/mutation/editors/jobspec.go create mode 100644 pkg/mutation/editors/jobspec_test.go diff --git a/pkg/mutation/editors/cronjobspec.go b/pkg/mutation/editors/cronjobspec.go new file mode 100644 index 00000000..4be5a769 --- /dev/null +++ b/pkg/mutation/editors/cronjobspec.go @@ -0,0 +1,56 @@ +package editors + +import ( + batchv1 "k8s.io/api/batch/v1" +) + +// CronJobSpecEditor provides a typed API for mutating a Kubernetes CronJobSpec. +// +// Note: spec.suspend is NOT exposed here — it is managed by the framework's suspension +// system via DefaultSuspendMutationHandler. +type CronJobSpecEditor struct { + spec *batchv1.CronJobSpec +} + +// NewCronJobSpecEditor creates a new CronJobSpecEditor for the given CronJobSpec. +func NewCronJobSpecEditor(spec *batchv1.CronJobSpec) *CronJobSpecEditor { + return &CronJobSpecEditor{spec: spec} +} + +// Raw returns the underlying *batchv1.CronJobSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *CronJobSpecEditor) Raw() *batchv1.CronJobSpec { + return e.spec +} + +// SetSchedule sets the cron schedule expression. +func (e *CronJobSpecEditor) SetSchedule(cron string) { + e.spec.Schedule = cron +} + +// SetConcurrencyPolicy sets the concurrency policy for the CronJob. +func (e *CronJobSpecEditor) SetConcurrencyPolicy(policy batchv1.ConcurrencyPolicy) { + e.spec.ConcurrencyPolicy = policy +} + +// SetStartingDeadlineSeconds sets the optional deadline in seconds for starting the job +// if it misses its scheduled time. +func (e *CronJobSpecEditor) SetStartingDeadlineSeconds(seconds int64) { + e.spec.StartingDeadlineSeconds = &seconds +} + +// SetSuccessfulJobsHistoryLimit sets the number of successful finished jobs to retain. +func (e *CronJobSpecEditor) SetSuccessfulJobsHistoryLimit(n int32) { + e.spec.SuccessfulJobsHistoryLimit = &n +} + +// SetFailedJobsHistoryLimit sets the number of failed finished jobs to retain. +func (e *CronJobSpecEditor) SetFailedJobsHistoryLimit(n int32) { + e.spec.FailedJobsHistoryLimit = &n +} + +// SetTimeZone sets the time zone for the cron schedule. +func (e *CronJobSpecEditor) SetTimeZone(tz string) { + e.spec.TimeZone = &tz +} diff --git a/pkg/mutation/editors/cronjobspec_test.go b/pkg/mutation/editors/cronjobspec_test.go new file mode 100644 index 00000000..1b28b09e --- /dev/null +++ b/pkg/mutation/editors/cronjobspec_test.go @@ -0,0 +1,58 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" +) + +func TestCronJobSpecEditor(t *testing.T) { + t.Run("SetSchedule", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetSchedule("*/5 * * * *") + assert.Equal(t, "*/5 * * * *", spec.Schedule) + }) + + t.Run("SetConcurrencyPolicy", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + assert.Equal(t, batchv1.ForbidConcurrent, spec.ConcurrencyPolicy) + }) + + t.Run("SetStartingDeadlineSeconds", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetStartingDeadlineSeconds(200) + assert.Equal(t, int64(200), *spec.StartingDeadlineSeconds) + }) + + t.Run("SetSuccessfulJobsHistoryLimit", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetSuccessfulJobsHistoryLimit(3) + assert.Equal(t, int32(3), *spec.SuccessfulJobsHistoryLimit) + }) + + t.Run("SetFailedJobsHistoryLimit", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetFailedJobsHistoryLimit(1) + assert.Equal(t, int32(1), *spec.FailedJobsHistoryLimit) + }) + + t.Run("SetTimeZone", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetTimeZone("America/New_York") + assert.Equal(t, "America/New_York", *spec.TimeZone) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/mutation/editors/jobspec.go b/pkg/mutation/editors/jobspec.go new file mode 100644 index 00000000..13bd1907 --- /dev/null +++ b/pkg/mutation/editors/jobspec.go @@ -0,0 +1,53 @@ +package editors + +import ( + batchv1 "k8s.io/api/batch/v1" +) + +// JobSpecEditor provides a typed API for mutating a Kubernetes JobSpec. +type JobSpecEditor struct { + spec *batchv1.JobSpec +} + +// NewJobSpecEditor creates a new JobSpecEditor for the given JobSpec. +func NewJobSpecEditor(spec *batchv1.JobSpec) *JobSpecEditor { + return &JobSpecEditor{spec: spec} +} + +// Raw returns the underlying *batchv1.JobSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *JobSpecEditor) Raw() *batchv1.JobSpec { + return e.spec +} + +// SetCompletions sets the desired number of successfully finished pods. +func (e *JobSpecEditor) SetCompletions(n int32) { + e.spec.Completions = &n +} + +// SetParallelism sets the maximum desired number of pods running at any given time. +func (e *JobSpecEditor) SetParallelism(n int32) { + e.spec.Parallelism = &n +} + +// SetBackoffLimit sets the number of retries before marking the job as failed. +func (e *JobSpecEditor) SetBackoffLimit(n int32) { + e.spec.BackoffLimit = &n +} + +// SetActiveDeadlineSeconds sets the duration in seconds relative to the start time +// that the job may be active before it is terminated. +func (e *JobSpecEditor) SetActiveDeadlineSeconds(seconds int64) { + e.spec.ActiveDeadlineSeconds = &seconds +} + +// SetTTLSecondsAfterFinished sets the TTL for cleaning up finished jobs. +func (e *JobSpecEditor) SetTTLSecondsAfterFinished(seconds int32) { + e.spec.TTLSecondsAfterFinished = &seconds +} + +// SetCompletionMode sets the completion mode of the job. +func (e *JobSpecEditor) SetCompletionMode(mode batchv1.CompletionMode) { + e.spec.CompletionMode = &mode +} diff --git a/pkg/mutation/editors/jobspec_test.go b/pkg/mutation/editors/jobspec_test.go new file mode 100644 index 00000000..ff421527 --- /dev/null +++ b/pkg/mutation/editors/jobspec_test.go @@ -0,0 +1,58 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" +) + +func TestJobSpecEditor(t *testing.T) { + t.Run("SetCompletions", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetCompletions(5) + assert.Equal(t, int32(5), *spec.Completions) + }) + + t.Run("SetParallelism", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetParallelism(3) + assert.Equal(t, int32(3), *spec.Parallelism) + }) + + t.Run("SetBackoffLimit", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetBackoffLimit(6) + assert.Equal(t, int32(6), *spec.BackoffLimit) + }) + + t.Run("SetActiveDeadlineSeconds", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetActiveDeadlineSeconds(300) + assert.Equal(t, int64(300), *spec.ActiveDeadlineSeconds) + }) + + t.Run("SetTTLSecondsAfterFinished", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetTTLSecondsAfterFinished(100) + assert.Equal(t, int32(100), *spec.TTLSecondsAfterFinished) + }) + + t.Run("SetCompletionMode", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetCompletionMode(batchv1.IndexedCompletion) + assert.Equal(t, batchv1.IndexedCompletion, *spec.CompletionMode) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} From cd2544b17bc7fbfaa24ed1b965c21944d796c4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:41:27 +0000 Subject: [PATCH 02/32] Add CronJob primitive package with Integration lifecycle Implements the CronJob primitive using IntegrationBuilder with Operational and Suspendable concepts. Key behaviors: - Operational status based on LastScheduleTime (Pending vs Operational) - Suspension via spec.suspend=true (DeleteOnSuspend=false) - Full pod-template mutation pipeline: metadata, cronjob spec, job spec, pod template metadata, pod spec, containers, init containers - Flavors for preserving labels, annotations, and pod template metadata Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/cronjob/builder.go | 139 ++++++ pkg/primitives/cronjob/builder_test.go | 251 ++++++++++ pkg/primitives/cronjob/flavors.go | 50 ++ pkg/primitives/cronjob/flavors_test.go | 142 ++++++ pkg/primitives/cronjob/handlers.go | 77 +++ pkg/primitives/cronjob/handlers_test.go | 149 ++++++ pkg/primitives/cronjob/mutator.go | 393 +++++++++++++++ pkg/primitives/cronjob/mutator_test.go | 638 ++++++++++++++++++++++++ pkg/primitives/cronjob/resource.go | 89 ++++ 9 files changed, 1928 insertions(+) create mode 100644 pkg/primitives/cronjob/builder.go create mode 100644 pkg/primitives/cronjob/builder_test.go create mode 100644 pkg/primitives/cronjob/flavors.go create mode 100644 pkg/primitives/cronjob/flavors_test.go create mode 100644 pkg/primitives/cronjob/handlers.go create mode 100644 pkg/primitives/cronjob/handlers_test.go create mode 100644 pkg/primitives/cronjob/mutator.go create mode 100644 pkg/primitives/cronjob/mutator_test.go create mode 100644 pkg/primitives/cronjob/resource.go diff --git a/pkg/primitives/cronjob/builder.go b/pkg/primitives/cronjob/builder.go new file mode 100644 index 00000000..7b42abed --- /dev/null +++ b/pkg/primitives/cronjob/builder.go @@ -0,0 +1,139 @@ +package cronjob + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + batchv1 "k8s.io/api/batch/v1" +) + +// Builder is a configuration helper for creating and customizing a CronJob Resource. +// +// It provides a fluent API for registering mutations, status handlers, and +// data extractors. This builder ensures that the resulting Resource is +// properly initialized and validated before use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*batchv1.CronJob, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided CronJob object. +// +// The CronJob object passed here serves as the "desired base state". During +// reconciliation, the Resource will attempt to make the cluster's state match +// this base state, modified by any registered mutations. +// +// The provided CronJob must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(cj *batchv1.CronJob) *Builder { + identityFunc := func(c *batchv1.CronJob) string { + return fmt.Sprintf("batch/v1/CronJob/%s/%s", c.Namespace, c.Name) + } + + base := generic.NewIntegrationBuilder[*batchv1.CronJob, *Mutator]( + cj, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the CronJob. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomFieldApplicator sets a custom strategy for applying the desired +// state to the existing CronJob in the cluster. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current *batchv1.CronJob, desired *batchv1.CronJob) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a reusable post-application "flavor" for +// the CronJob. +// +// If the provided flavor is nil, it is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*batchv1.CronJob](flavor)) + return b +} + +// WithCustomOperationalStatus overrides the default logic for determining if the +// CronJob is operational. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *batchv1.CronJob) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +func (b *Builder) WithCustomSuspendStatus( + handler func(*batchv1.CronJob) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the CronJob should be modified when +// the component is suspended. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the CronJob when the component is suspended. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*batchv1.CronJob) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// CronJob after it has been successfully reconciled. +func (b *Builder) WithDataExtractor( + extractor func(batchv1.CronJob) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(c *batchv1.CronJob) error { + return extractor(*c) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base CronJob object was provided. +// - The CronJob has both a name and a namespace set. +// +// If validation fails, an error is returned and the Resource should not be used. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/cronjob/builder_test.go b/pkg/primitives/cronjob/builder_test.go new file mode 100644 index 00000000..72f0d5a1 --- /dev/null +++ b/pkg/primitives/cronjob/builder_test.go @@ -0,0 +1,251 @@ +package cronjob + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder(t *testing.T) { + t.Parallel() + + t.Run("Build validation", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cronjob *batchv1.CronJob + expectedErr string + }{ + { + name: "nil cronjob", + cronjob: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + cronjob: &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + cronjob: &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid cronjob", + cronjob: &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.cronjob).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "batch/v1/CronJob/test-ns/test-cronjob", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(cj). + WithMutation(m). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) + }) + + t.Run("WithCustomFieldApplicator", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_ *batchv1.CronJob, _ *batchv1.CronJob) error { + applied = true + return nil + } + res, err := NewBuilder(cj). + WithCustomFieldApplicator(applicator). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.CustomFieldApplicator) + _ = res.base.CustomFieldApplicator(nil, nil) + assert.True(t, applied) + }) + + t.Run("WithFieldApplicationFlavor", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(cj). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) + }) + + t.Run("WithCustomOperationalStatus", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *batchv1.CronJob) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil + } + res, err := NewBuilder(cj). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ *batchv1.CronJob) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(cj). + WithCustomSuspendStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendStatusHandler) + status, err := res.base.SuspendStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("WithCustomSuspendMutation", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(cj). + WithCustomSuspendMutation(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendMutationHandler) + err = res.base.SuspendMutationHandler(nil) + assert.EqualError(t, err, "suspend error") + }) + + t.Run("WithCustomSuspendDeletionDecision", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ *batchv1.CronJob) bool { + return true + } + res, err := NewBuilder(cj). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.True(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ batchv1.CronJob) error { + called = true + return nil + } + res, err := NewBuilder(cj). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&batchv1.CronJob{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(cj). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/cronjob/flavors.go b/pkg/primitives/cronjob/flavors.go new file mode 100644 index 00000000..29ba4064 --- /dev/null +++ b/pkg/primitives/cronjob/flavors.go @@ -0,0 +1,50 @@ +package cronjob + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + "github.com/sourcehawk/operator-component-framework/pkg/flavors/utils" + batchv1 "k8s.io/api/batch/v1" +) + +// FieldApplicationFlavor defines a function signature for applying "flavors" to a resource. +// A flavor typically preserves certain fields from the current (live) object after the +// baseline field application has occurred. +type FieldApplicationFlavor flavors.FieldApplicationFlavor[*batchv1.CronJob] + +// PreserveCurrentLabels ensures that any labels present on the current live +// CronJob but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *batchv1.CronJob) error { + return flavors.PreserveCurrentLabels[*batchv1.CronJob]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live CronJob but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *batchv1.CronJob) error { + return flavors.PreserveCurrentAnnotations[*batchv1.CronJob]()(applied, current, desired) +} + +// PreserveCurrentPodTemplateLabels ensures that any labels present on the +// current live CronJob's pod template but missing from the applied +// (desired) object's pod template are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentPodTemplateLabels(applied, current, _ *batchv1.CronJob) error { + applied.Spec.JobTemplate.Spec.Template.Labels = utils.PreserveMap( + applied.Spec.JobTemplate.Spec.Template.Labels, + current.Spec.JobTemplate.Spec.Template.Labels, + ) + return nil +} + +// PreserveCurrentPodTemplateAnnotations ensures that any annotations present +// on the current live CronJob's pod template but missing from the applied +// (desired) object's pod template are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentPodTemplateAnnotations(applied, current, _ *batchv1.CronJob) error { + applied.Spec.JobTemplate.Spec.Template.Annotations = utils.PreserveMap( + applied.Spec.JobTemplate.Spec.Template.Annotations, + current.Spec.JobTemplate.Spec.Template.Annotations, + ) + return nil +} diff --git a/pkg/primitives/cronjob/flavors_test.go b/pkg/primitives/cronjob/flavors_test.go new file mode 100644 index 00000000..c1388eaa --- /dev/null +++ b/pkg/primitives/cronjob/flavors_test.go @@ -0,0 +1,142 @@ +package cronjob + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMutate_OrderingAndFlavors(t *testing.T) { + desired := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + Labels: map[string]string{"app": "desired"}, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + } + + t.Run("flavors run after baseline applicator", func(t *testing.T) { + current := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + Labels: map[string]string{"extra": "preserved"}, + }, + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(PreserveCurrentLabels). + Build() + + err := res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, "desired", current.Labels["app"]) + assert.Equal(t, "preserved", current.Labels["extra"]) + }) + + t.Run("flavors run in registration order", func(t *testing.T) { + current := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + + var order []string + flavor1 := func(_, _, _ *batchv1.CronJob) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *batchv1.CronJob) error { + order = append(order, "flavor2") + return nil + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(flavor1). + WithFieldApplicationFlavor(flavor2). + Build() + + err := res.Mutate(current) + require.NoError(t, err) + assert.Equal(t, []string{"flavor1", "flavor2"}, order) + }) + + t.Run("flavor error is returned with context", func(t *testing.T) { + current := &batchv1.CronJob{} + flavorErr := errors.New("boom") + flavor := func(_, _, _ *batchv1.CronJob) error { + return flavorErr + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(flavor). + Build() + + err := res.Mutate(current) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to apply field application flavor") + assert.True(t, errors.Is(err, flavorErr)) + }) +} + +func TestDefaultFlavors(t *testing.T) { + t.Run("PreserveCurrentLabels", func(t *testing.T) { + applied := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} + current := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current", "overlap": "current"}}} + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Labels["keep"]) + assert.Equal(t, "applied", applied.Labels["overlap"]) + assert.Equal(t, "current", applied.Labels["extra"]) + }) + + t.Run("PreserveCurrentAnnotations", func(t *testing.T) { + applied := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} + + err := PreserveCurrentAnnotations(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Annotations["keep"]) + assert.Equal(t, "current", applied.Annotations["extra"]) + }) + + t.Run("PreserveCurrentPodTemplateLabels", func(t *testing.T) { + applied := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}}}} + current := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}}}}}} + + err := PreserveCurrentPodTemplateLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Spec.JobTemplate.Spec.Template.Labels["keep"]) + assert.Equal(t, "current", applied.Spec.JobTemplate.Spec.Template.Labels["extra"]) + }) + + t.Run("PreserveCurrentPodTemplateAnnotations", func(t *testing.T) { + applied := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}}}} + current := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}}}}}} + + err := PreserveCurrentPodTemplateAnnotations(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Spec.JobTemplate.Spec.Template.Annotations["keep"]) + assert.Equal(t, "current", applied.Spec.JobTemplate.Spec.Template.Annotations["extra"]) + }) + + t.Run("handles nil maps safely", func(t *testing.T) { + applied := &batchv1.CronJob{} + current := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "current", applied.Labels["extra"]) + }) +} diff --git a/pkg/primitives/cronjob/handlers.go b/pkg/primitives/cronjob/handlers.go new file mode 100644 index 00000000..a9786dcf --- /dev/null +++ b/pkg/primitives/cronjob/handlers.go @@ -0,0 +1,77 @@ +package cronjob + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + batchv1 "k8s.io/api/batch/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining if a CronJob is operational. +// +// It considers a CronJob operational when it has scheduled at least once +// (Status.LastScheduleTime is not nil). If it has never been scheduled, +// the status is Pending. +// +// Failures are reported on the spawned Job resources, not on the CronJob itself. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, cj *batchv1.CronJob, +) (concepts.OperationalStatusWithReason, error) { + if cj.Status.LastScheduleTime == nil { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "CronJob has never been scheduled", + }, nil + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: fmt.Sprintf("CronJob last scheduled at %s", cj.Status.LastScheduleTime.Time.Format("2006-01-02T15:04:05Z")), + }, nil +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a CronJob when the component is suspended. +// +// It sets spec.suspend to true, which prevents the CronJob from creating new Job objects. +func DefaultSuspendMutationHandler(mutator *Mutator) error { + mutator.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + suspend := true + e.Raw().Suspend = &suspend + return nil + }) + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It reports Suspended when spec.suspend is true and no active jobs are running. +// It reports Suspending when spec.suspend is true but active jobs are still running. +func DefaultSuspensionStatusHandler(cj *batchv1.CronJob) (concepts.SuspensionStatusWithReason, error) { + if cj.Spec.Suspend != nil && *cj.Spec.Suspend { + if len(cj.Status.Active) > 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("CronJob suspended but %d active jobs still running", len(cj.Status.Active)), + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "CronJob suspended", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: "Waiting for suspend flag to be applied", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the CronJob +// when the parent component is suspended. +// +// It always returns false, meaning the CronJob is kept in the cluster with spec.suspend set to true. +func DefaultDeleteOnSuspendHandler(_ *batchv1.CronJob) bool { + return false +} diff --git a/pkg/primitives/cronjob/handlers_test.go b/pkg/primitives/cronjob/handlers_test.go new file mode 100644 index 00000000..270a7f9f --- /dev/null +++ b/pkg/primitives/cronjob/handlers_test.go @@ -0,0 +1,149 @@ +package cronjob + +import ( + "testing" + "time" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + cronjob *batchv1.CronJob + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "pending when never scheduled", + op: concepts.ConvergingOperationCreated, + cronjob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{}, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "CronJob has never been scheduled", + }, + { + name: "operational when scheduled at least once", + op: concepts.ConvergingOperationUpdated, + cronjob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{ + LastScheduleTime: &metav1.Time{Time: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)}, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "CronJob last scheduled at 2025-01-01T12:00:00Z", + }, + { + name: "operational regardless of converging operation", + op: concepts.ConvergingOperationNone, + cronjob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{ + LastScheduleTime: &metav1.Time{Time: time.Date(2025, 6, 15, 8, 30, 0, 0, time.UTC)}, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "CronJob last scheduled at 2025-06-15T08:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(tt.op, tt.cronjob) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + } + mutator := NewMutator(cj) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + require.NotNil(t, cj.Spec.Suspend) + assert.True(t, *cj.Spec.Suspend) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + cronjob *batchv1.CronJob + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended with no active jobs", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Suspend: ptr.To(true), + }, + Status: batchv1.CronJobStatus{}, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "CronJob suspended", + }, + { + name: "suspending with active jobs", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Suspend: ptr.To(true), + }, + Status: batchv1.CronJobStatus{ + Active: []corev1.ObjectReference{ + {Name: "job-1"}, + {Name: "job-2"}, + }, + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "CronJob suspended but 2 active jobs still running", + }, + { + name: "suspending when suspend flag not yet applied", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{}, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for suspend flag to be applied", + }, + { + name: "suspending when suspend is false", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Suspend: ptr.To(false), + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for suspend flag to be applied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultSuspensionStatusHandler(tt.cronjob) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + cj := &batchv1.CronJob{} + assert.False(t, DefaultDeleteOnSuspendHandler(cj)) +} diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go new file mode 100644 index 00000000..dae5e6fa --- /dev/null +++ b/pkg/primitives/cronjob/mutator.go @@ -0,0 +1,393 @@ +package cronjob + +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" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a cronjob Mutator +// only if its associated feature.ResourceFeature is enabled. +type Mutation feature.Mutation[*Mutator] + +type containerEdit struct { + selector selectors.ContainerSelector + edit func(*editors.ContainerEditor) error +} + +type containerPresenceOp struct { + name string + container *corev1.Container // nil for remove +} + +type featurePlan struct { + cronjobMetadataEdits []func(*editors.ObjectMetaEditor) error + cronjobSpecEdits []func(*editors.CronJobSpecEditor) error + jobSpecEdits []func(*editors.JobSpecEditor) error + podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit +} + +// Mutator is a high-level helper for modifying a Kubernetes CronJob. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the CronJob in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +type Mutator struct { + current *batchv1.CronJob + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given CronJob. +func NewMutator(current *batchv1.CronJob) *Mutator { + m := &Mutator{ + current: current, + } + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the CronJob's own metadata. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.cronjobMetadataEdits = append(m.active.cronjobMetadataEdits, edit) +} + +// EditCronJobSpec records a mutation for the CronJob's top-level spec. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditCronJobSpec(edit func(*editors.CronJobSpecEditor) error) { + if edit == nil { + return + } + m.active.cronjobSpecEdits = append(m.active.cronjobSpecEdits, edit) +} + +// EditJobSpec records a mutation for the CronJob's embedded job template spec. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditJobSpec(edit func(*editors.JobSpecEditor) error) { + if edit == nil { + return + } + m.active.jobSpecEdits = append(m.active.jobSpecEdits, edit) +} + +// EditPodTemplateMetadata records a mutation for the CronJob's pod template metadata. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.podTemplateMetadataEdits = append(m.active.podTemplateMetadataEdits, edit) +} + +// EditPodSpec records a mutation for the CronJob's pod spec. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { + if edit == nil { + return + } + m.active.podSpecEdits = append(m.active.podSpecEdits, edit) +} + +// EditContainers records a mutation for containers matching the given selector. +// +// Selector matching is evaluated against a snapshot taken after the current feature's +// container presence operations are applied. +// If either selector or edit function is nil, the registration is ignored. +func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.containerEdits = append(m.active.containerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EditInitContainers records a mutation for init containers matching the given selector. +// +// Selector matching is evaluated against a snapshot taken after the current feature's +// init container presence operations are applied. +// If either selector or edit function is nil, the registration is ignored. +func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureContainer records that a regular container must be present in the CronJob. +// If a container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureContainer(container corev1.Container) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveContainer records that a regular container should be removed by name. +func (m *Mutator) RemoveContainer(name string) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveContainers records that multiple regular containers should be removed by name. +func (m *Mutator) RemoveContainers(names []string) { + for _, name := range names { + m.RemoveContainer(name) + } +} + +// EnsureInitContainer records that an init container must be present in the CronJob. +// If an init container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveInitContainer records that an init container should be removed by name. +func (m *Mutator) RemoveInitContainer(name string) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveInitContainers records that multiple init containers should be removed by name. +func (m *Mutator) RemoveInitContainers(names []string) { + for _, name := range names { + m.RemoveInitContainer(name) + } +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the CronJob. +func (m *Mutator) EnsureContainerEnvVar(ev corev1.EnvVar) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(ev) + return nil + }) +} + +// RemoveContainerEnvVar records that an environment variable should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerEnvVar(name string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVar(name) + return nil + }) +} + +// RemoveContainerEnvVars records that multiple environment variables should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerEnvVars(names []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVars(names) + return nil + }) +} + +// EnsureContainerArg records that a command-line argument must be present +// in all containers of the CronJob. +func (m *Mutator) EnsureContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureArg(arg) + return nil + }) +} + +// RemoveContainerArg records that a command-line argument should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArg(arg) + return nil + }) +} + +// RemoveContainerArgs records that multiple command-line arguments should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerArgs(args []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArgs(args) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying CronJob. +// +// Execution Order: +// Features are applied in the order they were registered. +// Within each feature, mutations are applied in this fixed category order: +// 1. Object metadata edits +// 2. CronJobSpec edits +// 3. JobSpec edits +// 4. Pod template metadata edits +// 5. Pod spec edits +// 6. Regular container presence operations +// 7. Regular container edits +// 8. Init container presence operations +// 9. Init container edits +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.cronjobMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.cronjobMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. CronJobSpec + if len(plan.cronjobSpecEdits) > 0 { + editor := editors.NewCronJobSpecEditor(&m.current.Spec) + for _, edit := range plan.cronjobSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. JobSpec + if len(plan.jobSpecEdits) > 0 { + editor := editors.NewJobSpecEditor(&m.current.Spec.JobTemplate.Spec) + for _, edit := range plan.jobSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 4. Pod template metadata + if len(plan.podTemplateMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.Spec.JobTemplate.Spec.Template.ObjectMeta) + for _, edit := range plan.podTemplateMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 5. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec.JobTemplate.Spec.Template.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 6. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.Containers, op) + } + + // 7. Regular container edits + if len(plan.containerEdits) > 0 { + snapshots := make([]corev1.Container, len(m.current.Spec.JobTemplate.Spec.Template.Spec.Containers)) + for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.Containers { + m.current.Spec.JobTemplate.Spec.Template.Spec.Containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.Containers { + container := &m.current.Spec.JobTemplate.Spec.Template.Spec.Containers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.containerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + + // 8. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers, op) + } + + // 9. Init container edits + if len(plan.initContainerEdits) > 0 { + snapshots := make([]corev1.Container, len(m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers)) + for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers { + m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers { + container := &m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.initContainerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + } + + return nil +} + +func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { + found := -1 + for i, c := range *containers { + if c.Name == op.name { + found = i + break + } + } + + if op.container == nil { + // Remove + if found != -1 { + *containers = append((*containers)[:found], (*containers)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*containers)[found] = *op.container + } else { + *containers = append(*containers, *op.container) + } +} diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go new file mode 100644 index 00000000..86cbbac3 --- /dev/null +++ b/pkg/primitives/cronjob/mutator_test.go @@ -0,0 +1,638 @@ +package cronjob + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewMutator(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + assert.NotNil(t, m) + assert.Equal(t, cj, m.current) +} + +func TestMutator_EditObjectMetadata(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"cronjob": "label"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", cj.Labels["cronjob"]) +} + +func TestMutator_EditCronJobSpec(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/5 * * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "*/5 * * * *", cj.Spec.Schedule) + assert.Equal(t, batchv1.ForbidConcurrent, cj.Spec.ConcurrencyPolicy) +} + +func TestMutator_EditJobSpec(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetCompletions(1) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, int32(3), *cj.Spec.JobTemplate.Spec.BackoffLimit) + assert.Equal(t, int32(1), *cj.Spec.JobTemplate.Spec.Completions) +} + +func TestMutator_EditPodTemplateMetadata(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Annotations = map[string]string{"pod": "ann"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "ann", cj.Spec.JobTemplate.Spec.Template.Annotations["pod"]) +} + +func TestMutator_EditPodSpec(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.Raw().ServiceAccountName = "my-sa" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "my-sa", cj.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditContainers(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "c1-image" + return nil + }) + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "GLOBAL", Value: "true"}) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "c1-image", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EnvVars(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Env: []corev1.EnvVar{ + {Name: "KEEP", Value: "stay"}, + {Name: "CHANGE", Value: "old"}, + {Name: "REMOVE", Value: "gone"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) + m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) + + err := m.Apply() + require.NoError(t, err) + + env := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env + assert.Len(t, env, 3) + + findEnv := func(name string) *corev1.EnvVar { + for _, e := range env { + if e.Name == name { + return &e + } + } + return nil + } + + assert.NotNil(t, findEnv("KEEP")) + assert.Equal(t, "stay", findEnv("KEEP").Value) + assert.Equal(t, "new", findEnv("CHANGE").Value) + assert.Equal(t, "added", findEnv("ADD").Value) + assert.Nil(t, findEnv("REMOVE")) +} + +func TestMutator_Args(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Args + assert.Contains(t, args, "--keep") + assert.Contains(t, args, "--change=old") + assert.Contains(t, args, "--change=new") + assert.Contains(t, args, "--add") + assert.NotContains(t, args, "--remove") +} + +func TestMutator_ContainerPresence(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + m.RemoveContainer("sidecar") + m.EnsureContainer(corev1.Container{Name: "new-container", Image: "new-image"}) + + err := m.Apply() + require.NoError(t, err) + + containers := cj.Spec.JobTemplate.Spec.Template.Spec.Containers + require.Len(t, containers, 2) + assert.Equal(t, "app", containers[0].Name) + assert.Equal(t, "app-new-image", containers[0].Image) + assert.Equal(t, "new-container", containers[1].Name) + assert.Equal(t, "new-image", containers[1].Image) +} + +func TestMutator_InitContainers(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "new-image" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "new-image", cj.Spec.JobTemplate.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) + m.RemoveInitContainers([]string{"init-1"}) + + err := m.Apply() + require.NoError(t, err) + + initContainers := cj.Spec.JobTemplate.Spec.Template.Spec.InitContainers + require.Len(t, initContainers, 1) + assert.Equal(t, "init-2", initContainers[0].Name) +} + +func TestMutator_Errors(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + return errors.New("boom") + }) + + err := m.Apply() + assert.Error(t, err) + assert.Equal(t, "boom", err.Error()) +} + +func TestMutator_NilSafety(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + }, + }, + } + m := NewMutator(cj) + + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditInitContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditInitContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditPodTemplateMetadata(nil) + m.EditObjectMetadata(nil) + m.EditCronJobSpec(nil) + m.EditJobSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_Order(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + }, + }, + } + + var order []string + + m := NewMutator(cj) + // Register in reverse order to verify execution order + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + order = append(order, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + order = append(order, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "podmeta") + return nil + }) + m.EditJobSpec(func(_ *editors.JobSpecEditor) error { + order = append(order, "jobspec") + return nil + }) + m.EditCronJobSpec(func(_ *editors.CronJobSpecEditor) error { + order = append(order, "cronjobspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "cronjobmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"cronjobmeta", "cronjobspec", "jobspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + + // Feature A + m.beginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/10 * * * *") + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B + m.beginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 * * * *") + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "0 * * * *", cj.Spec.Schedule) + assert.Equal(t, "v3", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Should still match using snapshot + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + // Should NOT match in this apply pass + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "should-not-be-set" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "app-v2", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + + // Register edit first + m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "edited-image" + return nil + }) + + // Register presence later + m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) + + err := m.Apply() + require.NoError(t, err) + + containers := cj.Spec.JobTemplate.Spec.Template.Spec.Containers + require.Len(t, containers, 1) + assert.Equal(t, "edited-image", containers[0].Image) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + + // Feature A renames container + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Feature B selects by the new name + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2-image" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "app-v2", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_EditMetadata(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"cronjob": "label"} + return nil + }) + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Annotations = map[string]string{"pod": "ann"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", cj.Labels["cronjob"]) + assert.Equal(t, "ann", cj.Spec.JobTemplate.Spec.Template.Annotations["pod"]) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + + var executionOrder []string + + // Register in reverse order of expected execution + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + executionOrder = append(executionOrder, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + executionOrder = append(executionOrder, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "podmeta") + return nil + }) + m.EditJobSpec(func(_ *editors.JobSpecEditor) error { + executionOrder = append(executionOrder, "jobspec") + return nil + }) + m.EditCronJobSpec(func(_ *editors.CronJobSpecEditor) error { + executionOrder = append(executionOrder, "cronjobspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "cronjobmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "cronjobmeta", + "cronjobspec", + "jobspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} diff --git a/pkg/primitives/cronjob/resource.go b/pkg/primitives/cronjob/resource.go new file mode 100644 index 00000000..16274f1b --- /dev/null +++ b/pkg/primitives/cronjob/resource.go @@ -0,0 +1,89 @@ +// Package cronjob provides a builder and resource for managing Kubernetes CronJobs. +package cronjob + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + batchv1 "k8s.io/api/batch/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultFieldApplicator replaces current with a deep copy of desired. +func DefaultFieldApplicator(current, desired *batchv1.CronJob) error { + *current = *desired.DeepCopy() + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes CronJob within a controller's +// reconciliation loop. +// +// It implements several component interfaces to integrate with the operator-component-framework: +// - component.Resource: for basic identity and mutation behavior. +// - component.Operational: for operational status tracking. +// - component.Suspendable: for controlled suspension via spec.suspend. +// - component.DataExtractable: for exporting information after successful reconciliation. +type Resource struct { + base *generic.IntegrationResource[*batchv1.CronJob, *Mutator] +} + +// Identity returns a unique identifier for the CronJob in the format +// "batch/v1/CronJob//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a copy of the underlying Kubernetes CronJob object. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes CronJob into the desired state. +// +// The mutation process follows a specific order: +// 1. Core State: The current object is reset to the desired base state, or +// modified via a custom field applicator if one is configured. +// 2. Feature Mutations: All registered feature-based mutations are applied. +// 3. Suspension: If the resource is in a suspending state, the suspension +// logic (setting spec.suspend = true) is applied. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus reports the CronJob's operational status. +// +// By default, it uses DefaultOperationalStatusHandler, which reports Pending +// when the CronJob has never been scheduled and Operational when it has +// scheduled at least once. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the CronJob should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it returns false — the CronJob is kept with spec.suspend set to true. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the suspension of the CronJob. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior sets spec.suspend to true. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which reports Suspended +// when spec.suspend is true and no active jobs are running. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled CronJob. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From c6c4288b14bb469f566af09573fe84ecd309b687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:23 +0000 Subject: [PATCH 03/32] Add CronJob primitive documentation Documents the Integration lifecycle, mutation pipeline ordering, editors (CronJobSpec, JobSpec, PodSpec, Container, ObjectMeta), operational status semantics, and suspension behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 224 +++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/primitives/cronjob.md diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md new file mode 100644 index 00000000..6a416bd1 --- /dev/null +++ b/docs/primitives/cronjob.md @@ -0,0 +1,224 @@ +# CronJob Primitive + +The `cronjob` primitive is the framework's built-in integration abstraction for managing Kubernetes `CronJob` resources. It integrates with the component lifecycle through the Operational and Suspendable concepts, and provides a rich mutation API for managing the CronJob schedule, job template, pod spec, and containers. + +## Capabilities + +| Capability | Detail | +|--------------------------|----------------------------------------------------------------------------------------------| +| **Operational tracking** | Reports `Pending` (never scheduled) or `Operational` (has scheduled at least once) | +| **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | + +## Building a CronJob Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" + +base := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-cleanup", + Namespace: owner.Namespace, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "cleanup", Image: "cleanup:latest"}, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, +} + +resource, err := cronjob.NewBuilder(base). + WithFieldApplicationFlavor(cronjob.PreserveCurrentLabels). + WithMutation(MyScheduleMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `CronJob` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. + +```go +func MyScheduleMutation(version string) cronjob.Mutation { + return cronjob.Mutation{ + Name: "my-schedule", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *cronjob.Mutator) error { + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 */6 * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + return nil + }) + return nil + }, + } +} +``` + +### Boolean-gated mutations + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TimeZoneMutation(version string, enabled bool) cronjob.Mutation { + return cronjob.Mutation{ + Name: "timezone", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *cronjob.Mutator) error { + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetTimeZone("America/New_York") + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded. + +| Step | Category | What it affects | +|---|---|---| +| 1 | CronJob metadata edits | Labels and annotations on the `CronJob` object | +| 2 | CronJobSpec edits | Schedule, concurrency policy, time zone, history limits | +| 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL | +| 4 | Pod template metadata edits | Labels and annotations on the pod template | +| 5 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 6 | Regular container presence | Adding or removing containers from `spec.containers` | +| 7 | Regular container edits | Env vars, args, resources (snapshot taken after step 6) | +| 8 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) | + +Container edits (steps 7 and 9) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. + +## Editors + +### CronJobSpecEditor + +Controls CronJob-level settings via `m.EditCronJobSpec`. + +Available methods: `SetSchedule`, `SetConcurrencyPolicy`, `SetStartingDeadlineSeconds`, `SetSuccessfulJobsHistoryLimit`, `SetFailedJobsHistoryLimit`, `SetTimeZone`, `Raw`. + +```go +m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 2 * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + e.SetFailedJobsHistoryLimit(1) + return nil +}) +``` + +Note: `spec.suspend` is not exposed through the editor — it is managed by the framework's suspension system. + +### JobSpecEditor + +Controls the embedded job template spec via `m.EditJobSpec`. + +Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, `SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetTTLSecondsAfterFinished(3600) + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, `SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("cleanup-sa") + e.Raw().RestartPolicy = corev1.RestartPolicyOnFailure + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a [selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DRY_RUN", Value: "false"}) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `CronJob` object itself, or `m.EditPodTemplateMetadata` to target the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Convenience Methods + +The `Mutator` also exposes convenience wrappers that target all containers at once: + +| Method | Equivalent to | +|-------------------------------|---------------------------------------------------------------| +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Operational Status + +The CronJob primitive reports operational status based on the CronJob's scheduling history: + +| Status | Condition | +|---------------|------------------------------------| +| `Pending` | `Status.LastScheduleTime == nil` | +| `Operational` | `Status.LastScheduleTime != nil` | + +Failures are reported on the spawned Job resources, not on the CronJob itself. + +## Suspension + +When the component is suspended, the CronJob primitive sets `spec.suspend = true`. This prevents the CronJob controller from creating new Job objects. Existing active jobs continue to run. + +| Status | Condition | +|--------------|----------------------------------------------------------| +| `Suspended` | `spec.suspend == true` and no active jobs | +| `Suspending` | `spec.suspend == true` but active jobs still running | +| `Suspending` | Waiting for suspend flag to be applied | + +On unsuspend, the `DefaultFieldApplicator` restores the desired state (without `spec.suspend = true`), allowing the CronJob to resume scheduling. + +The CronJob is never deleted on suspend (`DeleteOnSuspend = false`). + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in the same mutation resolve correctly. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. From cbae2f02eedab3f21f60eef7d0439da321bf7bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:44:42 +0000 Subject: [PATCH 04/32] Add CronJob primitive example Demonstrates building a CronJob resource with version-gated mutations, tracing/metrics features, field preservation flavors, suspension via spec.suspend, and data extraction. Uses the shared ExampleApp CRD and a fake client for standalone execution. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/cronjob-primitive/README.md | 32 +++++ examples/cronjob-primitive/app/controller.go | 69 ++++++++++ .../cronjob-primitive/features/mutations.go | 67 ++++++++++ examples/cronjob-primitive/main.go | 122 ++++++++++++++++++ .../cronjob-primitive/resources/cronjob.go | 81 ++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 examples/cronjob-primitive/README.md create mode 100644 examples/cronjob-primitive/app/controller.go create mode 100644 examples/cronjob-primitive/features/mutations.go create mode 100644 examples/cronjob-primitive/main.go create mode 100644 examples/cronjob-primitive/resources/cronjob.go diff --git a/examples/cronjob-primitive/README.md b/examples/cronjob-primitive/README.md new file mode 100644 index 00000000..41bb98e1 --- /dev/null +++ b/examples/cronjob-primitive/README.md @@ -0,0 +1,32 @@ +# CronJob Primitive Example + +This example demonstrates the usage of the `cronjob` primitive within the operator component framework. +It shows how to manage a Kubernetes CronJob as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a CronJob with a schedule, job template, and containers. +- **Feature Mutations**: Applying conditional changes (tracing env vars, metrics annotations, version-based image updates) using the `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools. +- **Suspension**: Suspending the CronJob by setting `spec.suspend = true`. +- **Data Extraction**: Harvesting information from the reconciled resource. + +## Directory Structure + +- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. +- `features/`: Contains modular feature definitions: + - `mutations.go`: tracing env vars, metrics annotations, and version-based image updates. +- `resources/`: Contains the central `NewCronJobResource` factory that assembles all features using the `cronjob.Builder`. +- `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/cronjob-primitive/main.go +``` + +This will: +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components through multiple spec changes. +4. Print the resulting status conditions. diff --git a/examples/cronjob-primitive/app/controller.go b/examples/cronjob-primitive/app/controller.go new file mode 100644 index 00000000..b5c469a1 --- /dev/null +++ b/examples/cronjob-primitive/app/controller.go @@ -0,0 +1,69 @@ +// Package app provides a sample controller using the cronjob primitive. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewCronJobResource is a factory function to create the cronjob resource. + NewCronJobResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + // 1. Build the cronjob resource for this owner. + cronJobResource, err := r.NewCronJobResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the cronjob. + comp, err := component.NewComponentBuilder(). + WithName("example-cronjob"). + WithConditionType("CronJobReady"). + WithResource(cronJobResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/cronjob-primitive/features/mutations.go b/examples/cronjob-primitive/features/mutations.go new file mode 100644 index 00000000..e5758303 --- /dev/null +++ b/examples/cronjob-primitive/features/mutations.go @@ -0,0 +1,67 @@ +package features + +import ( + "fmt" + + "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/cronjob" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds tracing environment variables to all containers. +func TracingFeature(enabled bool) cronjob.Mutation { + return cronjob.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *cronjob.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "JAEGER_AGENT_HOST", + Value: "localhost", + }) + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "TRACING_ENABLED", + Value: "true", + }) + return nil + }, + } +} + +// MetricsFeature adds metrics annotations to the pod template. +func MetricsFeature(enabled bool) cronjob.Mutation { + return cronjob.Mutation{ + Name: "Metrics", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *cronjob.Mutator) error { + m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("prometheus.io/scrape", "true") + meta.EnsureAnnotation("prometheus.io/port", "9090") + return nil + }) + return nil + }, + } +} + +// VersionFeature sets the image version and a label. +func VersionFeature(version string) cronjob.Mutation { + return cronjob.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *cronjob.Mutator) error { + m.EditContainers(selectors.ContainerNamed("worker"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-worker:%s", version) + return nil + }) + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} diff --git a/examples/cronjob-primitive/main.go b/examples/cronjob-primitive/main.go new file mode 100644 index 00000000..96705a5f --- /dev/null +++ b/examples/cronjob-primitive/main.go @@ -0,0 +1,122 @@ +// Package main is the entry point for the cronjob primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/resources" + batchv1 "k8s.io/api/batch/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client for the example. + scheme := runtime.NewScheme() + if err := app.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := batchv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add batch/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + // Pass the cronjob resource factory. + NewCronJobResource: resources.NewCronJobResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: true, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/cronjob-primitive/resources/cronjob.go b/examples/cronjob-primitive/resources/cronjob.go new file mode 100644 index 00000000..3210d70d --- /dev/null +++ b/examples/cronjob-primitive/resources/cronjob.go @@ -0,0 +1,81 @@ +// Package resources provides resource implementations for the cronjob primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewCronJobResource constructs a cronjob primitive resource with all the features. +func NewCronJobResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base CronJob object. + base := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-cronjob", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + ConcurrencyPolicy: batchv1.ForbidConcurrent, + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "worker", + Image: "my-worker:latest", // Will be overwritten by VersionFeature + }, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, + } + + // 2. Initialize the cronjob builder. + builder := cronjob.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.VersionFeature(owner.Spec.Version)) + builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) + builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics)) + + // 4. Configure flavors. + builder.WithFieldApplicationFlavor(cronjob.PreserveCurrentLabels) + builder.WithFieldApplicationFlavor(cronjob.PreserveCurrentAnnotations) + + // 5. Data extraction (optional). + builder.WithDataExtractor(func(cj batchv1.CronJob) error { + fmt.Printf("Reconciling CronJob: %s, schedule: %s\n", cj.Name, cj.Spec.Schedule) + + y, err := yaml.Marshal(cj) + if err != nil { + return fmt.Errorf("failed to marshal cronjob to yaml: %w", err) + } + fmt.Printf("Complete CronJob Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} From 009f807d4a2e9cb32d5dd2e5ec9d703db9ff8932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 03:41:12 +0000 Subject: [PATCH 05/32] update golangci-lint to be valid v2 --- .golangci.yml | 98 ++++++++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 4da2479f..a56d3dd9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,27 +1,20 @@ version: "2" - -run: - timeout: 5m - linters: - disable-all: true + default: none enable: - - errcheck - - govet - - ineffassign - - staticcheck - - unused - asciicheck - bidichk - bodyclose - dupl + - errcheck - errname - errorlint - goconst - gocritic - gocyclo - godot - - revive + - govet + - ineffassign - misspell - nakedret - nilerr @@ -30,47 +23,56 @@ linters: - prealloc - predeclared - reassign + - revive + - staticcheck - unconvert - unparam + - unused - whitespace - + settings: + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + exclusions: + generated: lax + rules: + - linters: + - dupl + - goconst + - revive + path: _test\.go + - linters: + - revive + text: dot-imports + paths: + - third_party$ + - builtin$ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 formatters: enable: - gofmt - goimports - -issues: - exclude-rules: - - path: _test\.go - linters: - - dupl - - goconst - - revive - - text: "dot-imports" - linters: - - revive - exclude-use-default: false - max-issues-per-linter: 0 - max-same-issues: 0 - -linters-settings: - revive: - rules: - - name: blank-imports - - name: context-as-first-argument - - name: context-keys-type - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - - name: if-return - - name: increment-decrement - - name: var-naming - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow - - name: errorf + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ From e244f2085303fb3a9c43f0dcffce99fdecb70387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 03:41:19 +0000 Subject: [PATCH 06/32] address linter warnings --- .../cronjob-primitive/features/mutations.go | 1 + internal/generic/resource_integration_test.go | 1 - internal/generic/resource_task_test.go | 1 - pkg/component/component_test.go | 4 +- pkg/component/suite_test.go | 4 +- pkg/primitives/cronjob/handlers.go | 2 +- pkg/primitives/cronjob/mutator.go | 154 +++++++++--------- 7 files changed, 82 insertions(+), 85 deletions(-) diff --git a/examples/cronjob-primitive/features/mutations.go b/examples/cronjob-primitive/features/mutations.go index e5758303..710a2345 100644 --- a/examples/cronjob-primitive/features/mutations.go +++ b/examples/cronjob-primitive/features/mutations.go @@ -1,3 +1,4 @@ +// Package features contains example CronJob mutation features. package features import ( diff --git a/internal/generic/resource_integration_test.go b/internal/generic/resource_integration_test.go index 45ade104..8eb77671 100644 --- a/internal/generic/resource_integration_test.go +++ b/internal/generic/resource_integration_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/internal/generic/resource_task_test.go b/internal/generic/resource_task_test.go index 01134434..f111452d 100644 --- a/internal/generic/resource_task_test.go +++ b/internal/generic/resource_task_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/pkg/component/component_test.go b/pkg/component/component_test.go index 03b856af..53d5b7c3 100644 --- a/pkg/component/component_test.go +++ b/pkg/component/component_test.go @@ -10,8 +10,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - . "github.com/onsi/ginkgo/v2" //nolint:revive - . "github.com/onsi/gomega" //nolint:revive + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" ) diff --git a/pkg/component/suite_test.go b/pkg/component/suite_test.go index addbeb98..fc0880ef 100644 --- a/pkg/component/suite_test.go +++ b/pkg/component/suite_test.go @@ -19,8 +19,8 @@ import ( ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" - . "github.com/onsi/ginkgo/v2" //nolint:revive - . "github.com/onsi/gomega" //nolint:revive + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) var ( diff --git a/pkg/primitives/cronjob/handlers.go b/pkg/primitives/cronjob/handlers.go index a9786dcf..5314dd5f 100644 --- a/pkg/primitives/cronjob/handlers.go +++ b/pkg/primitives/cronjob/handlers.go @@ -27,7 +27,7 @@ func DefaultOperationalStatusHandler( return concepts.OperationalStatusWithReason{ Status: concepts.OperationalStatusOperational, - Reason: fmt.Sprintf("CronJob last scheduled at %s", cj.Status.LastScheduleTime.Time.Format("2006-01-02T15:04:05Z")), + Reason: fmt.Sprintf("CronJob last scheduled at %s", cj.Status.LastScheduleTime.Format("2006-01-02T15:04:05Z")), }, nil } diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index dae5e6fa..f67044e7 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -261,104 +261,102 @@ func (m *Mutator) RemoveContainerArgs(args []string) { // 9. Init container edits func (m *Mutator) Apply() error { for _, plan := range m.plans { - // 1. Object metadata - if len(plan.cronjobMetadataEdits) > 0 { - editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) - for _, edit := range plan.cronjobMetadataEdits { - if err := edit(editor); err != nil { - return err - } - } + if err := m.applyPlan(plan); err != nil { + return err } + } - // 2. CronJobSpec - if len(plan.cronjobSpecEdits) > 0 { - editor := editors.NewCronJobSpecEditor(&m.current.Spec) - for _, edit := range plan.cronjobSpecEdits { - if err := edit(editor); err != nil { - return err - } + return nil +} + +func (m *Mutator) applyPlan(plan featurePlan) error { + // 1. Object metadata + if len(plan.cronjobMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.cronjobMetadataEdits { + if err := edit(editor); err != nil { + return err } } + } - // 3. JobSpec - if len(plan.jobSpecEdits) > 0 { - editor := editors.NewJobSpecEditor(&m.current.Spec.JobTemplate.Spec) - for _, edit := range plan.jobSpecEdits { - if err := edit(editor); err != nil { - return err - } + // 2. CronJobSpec + if len(plan.cronjobSpecEdits) > 0 { + editor := editors.NewCronJobSpecEditor(&m.current.Spec) + for _, edit := range plan.cronjobSpecEdits { + if err := edit(editor); err != nil { + return err } } + } - // 4. Pod template metadata - if len(plan.podTemplateMetadataEdits) > 0 { - editor := editors.NewObjectMetaEditor(&m.current.Spec.JobTemplate.Spec.Template.ObjectMeta) - for _, edit := range plan.podTemplateMetadataEdits { - if err := edit(editor); err != nil { - return err - } + // 3. JobSpec + if len(plan.jobSpecEdits) > 0 { + editor := editors.NewJobSpecEditor(&m.current.Spec.JobTemplate.Spec) + for _, edit := range plan.jobSpecEdits { + if err := edit(editor); err != nil { + return err } } + } - // 5. Pod spec - if len(plan.podSpecEdits) > 0 { - editor := editors.NewPodSpecEditor(&m.current.Spec.JobTemplate.Spec.Template.Spec) - for _, edit := range plan.podSpecEdits { - if err := edit(editor); err != nil { - return err - } + // 4. Pod template metadata + if len(plan.podTemplateMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.Spec.JobTemplate.Spec.Template.ObjectMeta) + for _, edit := range plan.podTemplateMetadataEdits { + if err := edit(editor); err != nil { + return err } } + } - // 6. Regular container presence - for _, op := range plan.containerPresence { - applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.Containers, op) + // 5. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec.JobTemplate.Spec.Template.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } } + } - // 7. Regular container edits - if len(plan.containerEdits) > 0 { - snapshots := make([]corev1.Container, len(m.current.Spec.JobTemplate.Spec.Template.Spec.Containers)) - for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.Containers { - m.current.Spec.JobTemplate.Spec.Template.Spec.Containers[i].DeepCopyInto(&snapshots[i]) - } + // 6. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.Containers, op) + } - for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.Containers { - container := &m.current.Spec.JobTemplate.Spec.Template.Spec.Containers[i] - snapshot := &snapshots[i] - editor := editors.NewContainerEditor(container) - for _, ce := range plan.containerEdits { - if ce.selector(i, snapshot) { - if err := ce.edit(editor); err != nil { - return err - } - } - } - } - } + // 7. Regular container edits + if err := applyContainerEdits(m.current.Spec.JobTemplate.Spec.Template.Spec.Containers, plan.containerEdits); err != nil { + return err + } - // 8. Init container presence - for _, op := range plan.initContainerPresence { - applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers, op) - } + // 8. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers, op) + } - // 9. Init container edits - if len(plan.initContainerEdits) > 0 { - snapshots := make([]corev1.Container, len(m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers)) - for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers { - m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) - } + // 9. Init container edits + return applyContainerEdits(m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers, plan.initContainerEdits) +} - for i := range m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers { - container := &m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers[i] - snapshot := &snapshots[i] - editor := editors.NewContainerEditor(container) - for _, ce := range plan.initContainerEdits { - if ce.selector(i, snapshot) { - if err := ce.edit(editor); err != nil { - return err - } - } +func applyContainerEdits(containers []corev1.Container, edits []containerEdit) error { + if len(edits) == 0 { + return nil + } + + snapshots := make([]corev1.Container, len(containers)) + for i := range containers { + containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range containers { + container := &containers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range edits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err } } } From 465fe848c2f6d9d89d14828e76bc3ab1a8e7f68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:30:47 +0000 Subject: [PATCH 07/32] Address Copilot review comments on CronJob primitive - Fix container field paths in docs mutation ordering table to use full CronJob path (spec.jobTemplate.spec.template.spec.containers) - Use correct OperationPending status name in docs to match framework constant - Explicitly convert LastScheduleTime to UTC before formatting timestamp - Fix range variable address-of footgun in mutator_test.go findEnv helper Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 8 ++++---- pkg/primitives/cronjob/handlers.go | 2 +- pkg/primitives/cronjob/mutator_test.go | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index 6a416bd1..d10de0fd 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -96,9 +96,9 @@ Within a single mutation, edit operations are grouped into categories and applie | 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL | | 4 | Pod template metadata edits | Labels and annotations on the pod template | | 5 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | -| 6 | Regular container presence | Adding or removing containers from `spec.containers` | +| 6 | Regular container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.containers` | | 7 | Regular container edits | Env vars, args, resources (snapshot taken after step 6) | -| 8 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 8 | Init container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.initContainers` | | 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) | Container edits (steps 7 and 9) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. @@ -194,8 +194,8 @@ The CronJob primitive reports operational status based on the CronJob's scheduli | Status | Condition | |---------------|------------------------------------| -| `Pending` | `Status.LastScheduleTime == nil` | -| `Operational` | `Status.LastScheduleTime != nil` | +| `OperationPending` | `Status.LastScheduleTime == nil` | +| `Operational` | `Status.LastScheduleTime != nil` | Failures are reported on the spawned Job resources, not on the CronJob itself. diff --git a/pkg/primitives/cronjob/handlers.go b/pkg/primitives/cronjob/handlers.go index 5314dd5f..bb1762cf 100644 --- a/pkg/primitives/cronjob/handlers.go +++ b/pkg/primitives/cronjob/handlers.go @@ -27,7 +27,7 @@ func DefaultOperationalStatusHandler( return concepts.OperationalStatusWithReason{ Status: concepts.OperationalStatusOperational, - Reason: fmt.Sprintf("CronJob last scheduled at %s", cj.Status.LastScheduleTime.Format("2006-01-02T15:04:05Z")), + Reason: fmt.Sprintf("CronJob last scheduled at %s", cj.Status.LastScheduleTime.UTC().Format("2006-01-02T15:04:05Z")), }, nil } diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go index 86cbbac3..560a4962 100644 --- a/pkg/primitives/cronjob/mutator_test.go +++ b/pkg/primitives/cronjob/mutator_test.go @@ -162,9 +162,9 @@ func TestMutator_EnvVars(t *testing.T) { assert.Len(t, env, 3) findEnv := func(name string) *corev1.EnvVar { - for _, e := range env { - if e.Name == name { - return &e + for i := range env { + if env[i].Name == name { + return &env[i] } } return nil From 1c75a6c0dc67da84fdef42faf0ed56b4df439b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:30:11 +0000 Subject: [PATCH 08/32] Address Copilot review: fix doc status name and add resource tests Fix capabilities table in cronjob.md to use `OperationPending` instead of `Pending` to match the actual status value from the concepts package. Add resource_test.go for the cronjob primitive covering Identity, Object, Mutate, feature ordering, custom field applicator, ConvergingStatus, DeleteOnSuspend, Suspend, SuspensionStatus, and ExtractData. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 2 +- pkg/primitives/cronjob/resource_test.go | 378 ++++++++++++++++++++++++ 2 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 pkg/primitives/cronjob/resource_test.go diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index d10de0fd..e8b4415f 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -6,7 +6,7 @@ The `cronjob` primitive is the framework's built-in integration abstraction for | Capability | Detail | |--------------------------|----------------------------------------------------------------------------------------------| -| **Operational tracking** | Reports `Pending` (never scheduled) or `Operational` (has scheduled at least once) | +| **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) | | **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | | **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | | **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | diff --git a/pkg/primitives/cronjob/resource_test.go b/pkg/primitives/cronjob/resource_test.go new file mode 100644 index 00000000..63485ae5 --- /dev/null +++ b/pkg/primitives/cronjob/resource_test.go @@ -0,0 +1,378 @@ +package cronjob + +import ( + "errors" + "testing" + "time" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidCronJob() *batchv1.CronJob { + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cj", + Namespace: "test-ns", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "worker", Image: "worker:latest"}, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidCronJob()).Build() + require.NoError(t, err) + assert.Equal(t, "batch/v1/CronJob/test-ns/test-cj", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + cj := newValidCronJob() + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*batchv1.CronJob) + require.True(t, ok) + assert.Equal(t, cj.Name, got.Name) + assert.Equal(t, cj.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-cj", cj.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidCronJob() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + current := &batchv1.CronJob{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "0 2 * * *", current.Spec.Schedule) + assert.Equal(t, "worker", current.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidCronJob() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-env", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "FOO", Value: "BAR"}) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &batchv1.CronJob{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "0 2 * * *", current.Spec.Schedule) + assert.Equal(t, "BAR", current.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidCronJob() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error { + if e.Raw().Image == "v2" { + e.Raw().Image = "v3" + } + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &batchv1.CronJob{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "v3", current.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { + desired := newValidCronJob() + desired.Labels = map[string]string{"app": "test"} + + applicatorCalled := false + res, err := NewBuilder(desired). + WithCustomFieldApplicator(func(current, d *batchv1.CronJob) error { + applicatorCalled = true + current.Name = d.Name + current.Namespace = d.Namespace + current.Spec.Schedule = d.Spec.Schedule + return nil + }). + Build() + require.NoError(t, err) + + current := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"external": "label"}, + }, + } + require.NoError(t, res.Mutate(current)) + + assert.True(t, applicatorCalled) + assert.Equal(t, "0 2 * * *", current.Spec.Schedule) + assert.Equal(t, "label", current.Labels["external"]) + assert.NotContains(t, current.Labels, "app") + + t.Run("returns error", func(t *testing.T) { + res, err := NewBuilder(newValidCronJob()). + WithCustomFieldApplicator(func(_, _ *batchv1.CronJob) error { + return errors.New("applicator error") + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&batchv1.CronJob{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") + }) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, cj *batchv1.CronJob) (concepts.OperationalStatusWithReason, error) { + args := m.Called(op, cj) + return args.Get(0).(concepts.OperationalStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(cj *batchv1.CronJob) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(cj) + return args.Get(0).(concepts.SuspensionStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) Suspend(mut *Mutator) error { + args := m.Called(mut) + return args.Error(0) +} + +func (m *mockHandlers) DeleteOnSuspend(cj *batchv1.CronJob) bool { + args := m.Called(cj) + return args.Bool(0) +} + +func TestResource_ConvergingStatus(t *testing.T) { + cj := newValidCronJob() + + t.Run("calls custom handler", func(t *testing.T) { + m := &mockHandlers{} + statusOp := concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, cj).Return(statusOp, nil) + + res, err := NewBuilder(cj). + WithCustomOperationalStatus(m.ConvergingStatus). + Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) + + t.Run("uses default - pending", func(t *testing.T) { + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) + }) + + t.Run("uses default - operational", func(t *testing.T) { + cjScheduled := newValidCronJob() + now := metav1.NewTime(time.Now()) + cjScheduled.Status.LastScheduleTime = &now + + res, err := NewBuilder(cjScheduled).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + cj := newValidCronJob() + + t.Run("calls custom handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", cj).Return(true) + + res, err := NewBuilder(cj). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default - false", func(t *testing.T) { + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + cj := newValidCronJob() + + t.Run("registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := cj.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + require.NotNil(t, current.Spec.Suspend) + assert.True(t, *current.Spec.Suspend) + }) + + t.Run("uses custom mutation handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("Suspend", mock.Anything).Return(nil).Run(func(args mock.Arguments) { + mut := args.Get(0).(*Mutator) + mut.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + suspend := true + e.Raw().Suspend = &suspend + e.SetSchedule("0 0 31 2 *") // effectively disable + return nil + }) + }) + + res, err := NewBuilder(cj). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := cj.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + m.AssertExpectations(t) + require.NotNil(t, current.Spec.Suspend) + assert.True(t, *current.Spec.Suspend) + assert.Equal(t, "0 0 31 2 *", current.Spec.Schedule) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + t.Run("calls custom handler", func(t *testing.T) { + cj := newValidCronJob() + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", cj).Return(statusSuspended, nil) + + res, err := NewBuilder(cj). + WithCustomSuspendStatus(m.SuspensionStatus). + Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("uses default - suspending when not yet suspended", func(t *testing.T) { + cj := newValidCronJob() + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspending, status.Status) + }) + + t.Run("uses default - suspended when suspend=true and no active jobs", func(t *testing.T) { + cj := newValidCronJob() + suspend := true + cj.Spec.Suspend = &suspend + + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) +} + +func TestResource_ExtractData(t *testing.T) { + cj := newValidCronJob() + + var extractedImage string + res, err := NewBuilder(cj). + WithDataExtractor(func(c batchv1.CronJob) error { + extractedImage = c.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "worker:latest", extractedImage) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidCronJob()). + WithDataExtractor(func(_ batchv1.CronJob) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +} From 7c8e5accaeecba3d7005bbc82701a6c342f1a440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:48:43 +0000 Subject: [PATCH 09/32] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/cronjob/resource.go | 7 +++- pkg/primitives/cronjob/resource_test.go | 43 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/cronjob/resource.go b/pkg/primitives/cronjob/resource.go index 16274f1b..c82d2531 100644 --- a/pkg/primitives/cronjob/resource.go +++ b/pkg/primitives/cronjob/resource.go @@ -8,9 +8,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired. +// DefaultFieldApplicator replaces current with a deep copy of desired while +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) +// and shared-controller fields (OwnerReferences, Finalizers) from the original +// current object. func DefaultFieldApplicator(current, desired *batchv1.CronJob) error { + original := current.DeepCopy() *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/cronjob/resource_test.go b/pkg/primitives/cronjob/resource_test.go index 63485ae5..b1a991f9 100644 --- a/pkg/primitives/cronjob/resource_test.go +++ b/pkg/primitives/cronjob/resource_test.go @@ -176,6 +176,49 @@ func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { }) } +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cj", + Namespace: "test-ns", + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + } + desired := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cj", + Namespace: "test-ns", + Labels: map[string]string{"app": "test"}, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + assert.Equal(t, "0 2 * * *", current.Spec.Schedule) + assert.Equal(t, "test", current.Labels["app"]) + + // Server-managed fields are preserved + assert.Equal(t, "12345", current.ResourceVersion) + assert.Equal(t, "abc-def", string(current.UID)) + assert.Equal(t, int64(3), current.Generation) + + // Shared-controller fields are preserved + assert.Len(t, current.OwnerReferences, 1) + assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) + assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) +} + type mockHandlers struct { mock.Mock } From ad7dbb05eb185f49bac90bab00ed22e11c316253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= Date: Mon, 23 Mar 2026 00:42:29 +0000 Subject: [PATCH 10/32] Add timeout setting for golangci-lint --- .golangci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index a56d3dd9..c8d26213 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,8 @@ version: "2" + +run: + timeout: 5m + linters: default: none enable: From 505819488e75b2dad0d7724c408b17ab3dbb9b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= Date: Mon, 23 Mar 2026 00:42:50 +0000 Subject: [PATCH 11/32] Update pkg/primitives/cronjob/mutator.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/primitives/cronjob/mutator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index f67044e7..fb99189f 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -8,7 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" ) -// Mutation defines a mutation that is applied to a cronjob Mutator +// Mutation defines a mutation that is applied to a CronJob Mutator // only if its associated feature.ResourceFeature is enabled. type Mutation feature.Mutation[*Mutator] From ac1294fad4ab394137d7cb27c3f1e8733c5ea818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 02:57:55 +0000 Subject: [PATCH 12/32] Export BeginFeature to fix cross-package FeatureMutator interface The FeatureMutator interface in internal/generic used an unexported beginFeature() method, which meant primitive mutators in external packages (cronjob, deployment, configmap) could never satisfy the interface. The type assertion in ApplyMutations always returned false, so feature boundaries were silently skipped for all primitives. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/generic/mutate_helper.go | 4 ++-- internal/generic/mutate_helper_test.go | 8 ++++---- internal/generic/resource_workload.go | 2 +- pkg/primitives/configmap/mutator.go | 6 +++--- pkg/primitives/configmap/mutator_test.go | 4 ++-- pkg/primitives/cronjob/mutator.go | 6 +++--- pkg/primitives/cronjob/mutator_test.go | 8 ++++---- pkg/primitives/deployment/mutator.go | 10 +++++----- pkg/primitives/deployment/mutator_test.go | 8 ++++---- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index 48c4b483..baede18f 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -44,7 +44,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( for _, mutation := range mutations { if isFeatureMutator { - fm.beginFeature() + fm.BeginFeature() } if err := mutation.ApplyIntent(mutator); err != nil { @@ -60,7 +60,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( if suspender != nil { if isFeatureMutator { - fm.beginFeature() + fm.BeginFeature() } if err := suspender(mutator); err != nil { diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index 5f908953..af38c3ba 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -26,8 +26,8 @@ func (m *recordingMutator) Apply() error { return nil } -func (m *recordingMutator) beginFeature() { - m.recorder.record("mutator.beginFeature") +func (m *recordingMutator) BeginFeature() { + m.recorder.record("mutator.BeginFeature") } func TestApplyMutationsOrder(t *testing.T) { @@ -90,10 +90,10 @@ func TestApplyMutationsOrder(t *testing.T) { "defaultApplicator", "flavor1", "newMutator", - "mutator.beginFeature", + "mutator.BeginFeature", "mutation1", "mutator.Apply", - "mutator.beginFeature", + "mutator.BeginFeature", "suspender", "mutator.Apply", } diff --git a/internal/generic/resource_workload.go b/internal/generic/resource_workload.go index 6676dba5..49d7c360 100644 --- a/internal/generic/resource_workload.go +++ b/internal/generic/resource_workload.go @@ -16,7 +16,7 @@ type MutatorApplier interface { // FeatureMutator is implemented by workload mutators that support defining feature boundaries. type FeatureMutator interface { MutatorApplier - beginFeature() + BeginFeature() } // WorkloadResource is a generic internal resource implementation for long-running Kubernetes diff --git a/pkg/primitives/configmap/mutator.go b/pkg/primitives/configmap/mutator.go index 2a4dff23..a2871624 100644 --- a/pkg/primitives/configmap/mutator.go +++ b/pkg/primitives/configmap/mutator.go @@ -35,13 +35,13 @@ type Mutator struct { // NewMutator creates a new Mutator for the given ConfigMap. func NewMutator(cm *corev1.ConfigMap) *Mutator { m := &Mutator{cm: cm} - m.beginFeature() + m.BeginFeature() return m } -// beginFeature starts a new feature planning scope. All subsequent mutation +// BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/configmap/mutator_test.go b/pkg/primitives/configmap/mutator_test.go index 120b8014..029e706f 100644 --- a/pkg/primitives/configmap/mutator_test.go +++ b/pkg/primitives/configmap/mutator_test.go @@ -156,7 +156,7 @@ func TestMutator_MergeYAML_Composable(t *testing.T) { cm := newTestCM(map[string]string{"config.yaml": "a: 1\n"}) m := NewMutator(cm) m.MergeYAML("config.yaml", "b: 2\n") - m.beginFeature() + m.BeginFeature() m.MergeYAML("config.yaml", "c: 3\n") require.NoError(t, m.Apply()) @@ -207,7 +207,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { cm := newTestCM(nil) m := NewMutator(cm) m.SetEntry("feature1", "on") - m.beginFeature() + m.BeginFeature() m.SetEntry("feature2", "on") require.NoError(t, m.Apply()) diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index fb99189f..484c00f2 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -53,12 +53,12 @@ func NewMutator(current *batchv1.CronJob) *Mutator { m := &Mutator{ current: current, } - m.beginFeature() + m.BeginFeature() return m } -// beginFeature starts a new feature planning scope. -func (m *Mutator) beginFeature() { +// BeginFeature starts a new feature planning scope. +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go index 560a4962..db78fb2d 100644 --- a/pkg/primitives/cronjob/mutator_test.go +++ b/pkg/primitives/cronjob/mutator_test.go @@ -416,7 +416,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(cj) // Feature A - m.beginFeature() + m.BeginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("*/10 * * * *") return nil @@ -427,7 +427,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B - m.beginFeature() + m.BeginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("0 * * * *") return nil @@ -539,14 +539,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(cj) // Feature A renames container - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index 08cd4a2b..6f4ad5a5 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -58,18 +58,18 @@ func NewMutator(current *appsv1.Deployment) *Mutator { m := &Mutator{ current: current, } - m.beginFeature() + m.BeginFeature() return m } -// beginFeature starts a new feature planning scope. All subsequent mutation -// registrations will be grouped into this feature's plan until EndFeature -// or another beginFeature is called. +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until the next +// BeginFeature call. // // This is used to ensure that mutations from different features are applied // in registration order while maintaining internal category ordering within // each feature. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/deployment/mutator_test.go b/pkg/primitives/deployment/mutator_test.go index 77de5946..ef72ca75 100644 --- a/pkg/primitives/deployment/mutator_test.go +++ b/pkg/primitives/deployment/mutator_test.go @@ -484,7 +484,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(deploy) // Feature A: sets replicas to 2, image to v2 - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(2) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2" @@ -492,7 +492,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B: sets replicas to 3, image to v3 - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(3) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v3" @@ -574,14 +574,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(deploy) // Feature A renames container - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - this should work! - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil From 8f1e3a4e03cd5aa3c3f6589b47f88875c028c8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 03:04:31 +0000 Subject: [PATCH 13/32] fix linter --- internal/generic/builder_integration_test.go | 1 - internal/generic/builder_task_test.go | 1 - 2 files changed, 2 deletions(-) diff --git a/internal/generic/builder_integration_test.go b/internal/generic/builder_integration_test.go index ca854b95..b6a430ce 100644 --- a/internal/generic/builder_integration_test.go +++ b/internal/generic/builder_integration_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/internal/generic/builder_task_test.go b/internal/generic/builder_task_test.go index 5917e0d0..e4744b6b 100644 --- a/internal/generic/builder_task_test.go +++ b/internal/generic/builder_task_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( From 2e8cb126a162ad88a983e9108bd6db8e4080fae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 03:08:39 +0000 Subject: [PATCH 14/32] revert beginFeature exposed change --- internal/generic/mutate_helper.go | 4 ++-- internal/generic/mutate_helper_test.go | 8 ++++---- internal/generic/resource_workload.go | 2 +- pkg/primitives/configmap/mutator.go | 6 +++--- pkg/primitives/configmap/mutator_test.go | 4 ++-- pkg/primitives/cronjob/mutator.go | 6 +++--- pkg/primitives/cronjob/mutator_test.go | 8 ++++---- pkg/primitives/deployment/mutator.go | 8 ++++---- pkg/primitives/deployment/mutator_test.go | 8 ++++---- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index baede18f..48c4b483 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -44,7 +44,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( for _, mutation := range mutations { if isFeatureMutator { - fm.BeginFeature() + fm.beginFeature() } if err := mutation.ApplyIntent(mutator); err != nil { @@ -60,7 +60,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( if suspender != nil { if isFeatureMutator { - fm.BeginFeature() + fm.beginFeature() } if err := suspender(mutator); err != nil { diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index af38c3ba..5f908953 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -26,8 +26,8 @@ func (m *recordingMutator) Apply() error { return nil } -func (m *recordingMutator) BeginFeature() { - m.recorder.record("mutator.BeginFeature") +func (m *recordingMutator) beginFeature() { + m.recorder.record("mutator.beginFeature") } func TestApplyMutationsOrder(t *testing.T) { @@ -90,10 +90,10 @@ func TestApplyMutationsOrder(t *testing.T) { "defaultApplicator", "flavor1", "newMutator", - "mutator.BeginFeature", + "mutator.beginFeature", "mutation1", "mutator.Apply", - "mutator.BeginFeature", + "mutator.beginFeature", "suspender", "mutator.Apply", } diff --git a/internal/generic/resource_workload.go b/internal/generic/resource_workload.go index 49d7c360..6676dba5 100644 --- a/internal/generic/resource_workload.go +++ b/internal/generic/resource_workload.go @@ -16,7 +16,7 @@ type MutatorApplier interface { // FeatureMutator is implemented by workload mutators that support defining feature boundaries. type FeatureMutator interface { MutatorApplier - BeginFeature() + beginFeature() } // WorkloadResource is a generic internal resource implementation for long-running Kubernetes diff --git a/pkg/primitives/configmap/mutator.go b/pkg/primitives/configmap/mutator.go index a2871624..2a4dff23 100644 --- a/pkg/primitives/configmap/mutator.go +++ b/pkg/primitives/configmap/mutator.go @@ -35,13 +35,13 @@ type Mutator struct { // NewMutator creates a new Mutator for the given ConfigMap. func NewMutator(cm *corev1.ConfigMap) *Mutator { m := &Mutator{cm: cm} - m.BeginFeature() + m.beginFeature() return m } -// BeginFeature starts a new feature planning scope. All subsequent mutation +// beginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan. -func (m *Mutator) BeginFeature() { +func (m *Mutator) beginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/configmap/mutator_test.go b/pkg/primitives/configmap/mutator_test.go index 029e706f..120b8014 100644 --- a/pkg/primitives/configmap/mutator_test.go +++ b/pkg/primitives/configmap/mutator_test.go @@ -156,7 +156,7 @@ func TestMutator_MergeYAML_Composable(t *testing.T) { cm := newTestCM(map[string]string{"config.yaml": "a: 1\n"}) m := NewMutator(cm) m.MergeYAML("config.yaml", "b: 2\n") - m.BeginFeature() + m.beginFeature() m.MergeYAML("config.yaml", "c: 3\n") require.NoError(t, m.Apply()) @@ -207,7 +207,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { cm := newTestCM(nil) m := NewMutator(cm) m.SetEntry("feature1", "on") - m.BeginFeature() + m.beginFeature() m.SetEntry("feature2", "on") require.NoError(t, m.Apply()) diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index 484c00f2..fb99189f 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -53,12 +53,12 @@ func NewMutator(current *batchv1.CronJob) *Mutator { m := &Mutator{ current: current, } - m.BeginFeature() + m.beginFeature() return m } -// BeginFeature starts a new feature planning scope. -func (m *Mutator) BeginFeature() { +// beginFeature starts a new feature planning scope. +func (m *Mutator) beginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go index db78fb2d..560a4962 100644 --- a/pkg/primitives/cronjob/mutator_test.go +++ b/pkg/primitives/cronjob/mutator_test.go @@ -416,7 +416,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(cj) // Feature A - m.BeginFeature() + m.beginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("*/10 * * * *") return nil @@ -427,7 +427,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B - m.BeginFeature() + m.beginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("0 * * * *") return nil @@ -539,14 +539,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(cj) // Feature A renames container - m.BeginFeature() + m.beginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - m.BeginFeature() + m.beginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index 6f4ad5a5..a6eb281d 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -58,18 +58,18 @@ func NewMutator(current *appsv1.Deployment) *Mutator { m := &Mutator{ current: current, } - m.BeginFeature() + m.beginFeature() return m } -// BeginFeature starts a new feature planning scope. All subsequent mutation +// beginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan until the next -// BeginFeature call. +// beginFeature call. // // This is used to ensure that mutations from different features are applied // in registration order while maintaining internal category ordering within // each feature. -func (m *Mutator) BeginFeature() { +func (m *Mutator) beginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/deployment/mutator_test.go b/pkg/primitives/deployment/mutator_test.go index ef72ca75..77de5946 100644 --- a/pkg/primitives/deployment/mutator_test.go +++ b/pkg/primitives/deployment/mutator_test.go @@ -484,7 +484,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(deploy) // Feature A: sets replicas to 2, image to v2 - m.BeginFeature() + m.beginFeature() m.EnsureReplicas(2) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2" @@ -492,7 +492,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B: sets replicas to 3, image to v3 - m.BeginFeature() + m.beginFeature() m.EnsureReplicas(3) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v3" @@ -574,14 +574,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(deploy) // Feature A renames container - m.BeginFeature() + m.beginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - this should work! - m.BeginFeature() + m.beginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil From e445b38b7ce57c97938b317551566aaff9171476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:21 +0000 Subject: [PATCH 15/32] fix: remove beginFeature call from cronjob mutator constructor The constructor should not call beginFeature() because the generic helper in mutator_helper.go already calls fm.beginFeature() before each mutation, causing an empty feature plan to be created. Initialize plans inline instead, matching the deployment primitive fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/cronjob/mutator.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index fb99189f..28e665b7 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -52,8 +52,9 @@ type Mutator struct { func NewMutator(current *batchv1.CronJob) *Mutator { m := &Mutator{ current: current, + plans: []featurePlan{{}}, } - m.beginFeature() + m.active = &m.plans[0] return m } From b8419fd0cb3589171e47a655bd5da6a5f39bd3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:58:22 +0000 Subject: [PATCH 16/32] fix: preserve CronJob status in DefaultFieldApplicator and add tests Add generic.PreserveStatus call to DefaultFieldApplicator so that the live CronJob Status (LastScheduleTime, Active jobs, etc.) is not wiped during mutation. Add a dedicated test for status preservation and update documentation to describe the default field application behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 6 +++++ pkg/primitives/cronjob/resource.go | 7 ++--- pkg/primitives/cronjob/resource_test.go | 34 +++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index e8b4415f..5c550d29 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -11,6 +11,12 @@ The `cronjob` primitive is the framework's built-in integration abstraction for | **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | | **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +## Default Field Application + +`DefaultFieldApplicator` replaces the current CronJob with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. + +Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. + ## Building a CronJob Primitive ```go diff --git a/pkg/primitives/cronjob/resource.go b/pkg/primitives/cronjob/resource.go index c82d2531..88c13b7c 100644 --- a/pkg/primitives/cronjob/resource.go +++ b/pkg/primitives/cronjob/resource.go @@ -9,13 +9,14 @@ import ( ) // DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) -// and shared-controller fields (OwnerReferences, Finalizers) from the original -// current object. +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), +// shared-controller fields (OwnerReferences, Finalizers), and the Status +// subresource from the original current object. func DefaultFieldApplicator(current, desired *batchv1.CronJob) error { original := current.DeepCopy() *current = *desired.DeepCopy() generic.PreserveServerManagedFields(current, original) + generic.PreserveStatus(current, original) return nil } diff --git a/pkg/primitives/cronjob/resource_test.go b/pkg/primitives/cronjob/resource_test.go index b1a991f9..37e6840f 100644 --- a/pkg/primitives/cronjob/resource_test.go +++ b/pkg/primitives/cronjob/resource_test.go @@ -219,6 +219,40 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } +func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { + now := metav1.NewTime(time.Now()) + current := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cj", + Namespace: "test-ns", + }, + Status: batchv1.CronJobStatus{ + Active: []corev1.ObjectReference{{Name: "test-cj-12345"}}, + LastScheduleTime: &now, + }, + } + desired := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cj", + Namespace: "test-ns", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 */6 * * *", + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec is applied + assert.Equal(t, "0 */6 * * *", current.Spec.Schedule) + + // Status from the live object is preserved + assert.Len(t, current.Status.Active, 1) + assert.Equal(t, "test-cj-12345", current.Status.Active[0].Name) + assert.NotNil(t, current.Status.LastScheduleTime) +} + type mockHandlers struct { mock.Mock } From ac412f20ebe9809d982a6796aa34396873930996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= Date: Mon, 23 Mar 2026 20:14:35 +0000 Subject: [PATCH 17/32] Fix/architectural issues (#43) * fix common issues --- internal/generic/mutate_helper.go | 4 +- internal/generic/mutate_helper_test.go | 8 +- internal/generic/resource_workload.go | 3 +- pkg/primitives/configmap/mutator.go | 4 +- pkg/primitives/configmap/mutator_test.go | 56 ++++++- pkg/primitives/deployment/mutator.go | 8 +- pkg/primitives/deployment/mutator_test.go | 162 ++++++++++++--------- pkg/primitives/deployment/resource_test.go | 2 +- 8 files changed, 162 insertions(+), 85 deletions(-) diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index 48c4b483..baede18f 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -44,7 +44,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( for _, mutation := range mutations { if isFeatureMutator { - fm.beginFeature() + fm.BeginFeature() } if err := mutation.ApplyIntent(mutator); err != nil { @@ -60,7 +60,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( if suspender != nil { if isFeatureMutator { - fm.beginFeature() + fm.BeginFeature() } if err := suspender(mutator); err != nil { diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index 5f908953..af38c3ba 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -26,8 +26,8 @@ func (m *recordingMutator) Apply() error { return nil } -func (m *recordingMutator) beginFeature() { - m.recorder.record("mutator.beginFeature") +func (m *recordingMutator) BeginFeature() { + m.recorder.record("mutator.BeginFeature") } func TestApplyMutationsOrder(t *testing.T) { @@ -90,10 +90,10 @@ func TestApplyMutationsOrder(t *testing.T) { "defaultApplicator", "flavor1", "newMutator", - "mutator.beginFeature", + "mutator.BeginFeature", "mutation1", "mutator.Apply", - "mutator.beginFeature", + "mutator.BeginFeature", "suspender", "mutator.Apply", } diff --git a/internal/generic/resource_workload.go b/internal/generic/resource_workload.go index 6676dba5..af7d317d 100644 --- a/internal/generic/resource_workload.go +++ b/internal/generic/resource_workload.go @@ -14,9 +14,10 @@ type MutatorApplier interface { } // FeatureMutator is implemented by workload mutators that support defining feature boundaries. +// The interface is exported so that primitive mutators in external packages can satisfy it. type FeatureMutator interface { MutatorApplier - beginFeature() + BeginFeature() } // WorkloadResource is a generic internal resource implementation for long-running Kubernetes diff --git a/pkg/primitives/configmap/mutator.go b/pkg/primitives/configmap/mutator.go index c1086ffc..2b747edf 100644 --- a/pkg/primitives/configmap/mutator.go +++ b/pkg/primitives/configmap/mutator.go @@ -42,9 +42,9 @@ func NewMutator(cm *corev1.ConfigMap) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation +// BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/configmap/mutator_test.go b/pkg/primitives/configmap/mutator_test.go index 120b8014..5ab4de2b 100644 --- a/pkg/primitives/configmap/mutator_test.go +++ b/pkg/primitives/configmap/mutator_test.go @@ -156,7 +156,7 @@ func TestMutator_MergeYAML_Composable(t *testing.T) { cm := newTestCM(map[string]string{"config.yaml": "a: 1\n"}) m := NewMutator(cm) m.MergeYAML("config.yaml", "b: 2\n") - m.beginFeature() + m.BeginFeature() m.MergeYAML("config.yaml", "c: 3\n") require.NoError(t, m.Apply()) @@ -207,7 +207,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { cm := newTestCM(nil) m := NewMutator(cm) m.SetEntry("feature1", "on") - m.beginFeature() + m.BeginFeature() m.SetEntry("feature2", "on") require.NoError(t, m.Apply()) @@ -241,6 +241,58 @@ func TestMutator_RemoveBinary(t *testing.T) { assert.Equal(t, []byte("keep"), cm.BinaryData["other"]) } +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesExactlyOnePlan(t *testing.T) { + cm := newTestCM(nil) + m := NewMutator(cm) + + require.Len(t, m.plans, 1, "NewMutator must create exactly one initial plan") + require.NotNil(t, m.active, "active plan must be set") + assert.Equal(t, &m.plans[0], m.active, "active must point to the first plan") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + cm := newTestCM(nil) + m := NewMutator(cm) + + m.BeginFeature() + require.Len(t, m.plans, 2, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[1], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 3) + assert.Equal(t, &m.plans[2], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + cm := newTestCM(nil) + m := NewMutator(cm) + + // Record a mutation in the initial plan + m.SetEntry("f0", "val0") + + // Start a new feature and record a different mutation + m.BeginFeature() + m.SetEntry("f1", "val1") + + // The initial plan should have exactly one data edit + assert.Len(t, m.plans[0].dataEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one data edit + assert.Len(t, m.plans[1].dataEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + // When no BeginFeature is called, Apply should process exactly one plan + cm := newTestCM(nil) + m := NewMutator(cm) + m.SetEntry("key", "value") + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, "value", cm.Data["key"]) +} + // --- ObjectMutator interface --- func TestMutator_ImplementsObjectMutator(_ *testing.T) { diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index d751f977..bb752a3c 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -63,14 +63,14 @@ func NewMutator(current *appsv1.Deployment) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation -// registrations will be grouped into this feature's plan until the next -// beginFeature call. +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until another +// BeginFeature is called. // // This is used to ensure that mutations from different features are applied // in registration order while maintaining internal category ordering within // each feature. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/deployment/mutator_test.go b/pkg/primitives/deployment/mutator_test.go index 77de5946..92b8b822 100644 --- a/pkg/primitives/deployment/mutator_test.go +++ b/pkg/primitives/deployment/mutator_test.go @@ -46,9 +46,9 @@ func TestMutator_EnvVars(t *testing.T) { assert.Len(t, env, 3) findEnv := func(name string) *corev1.EnvVar { - for _, e := range env { - if e.Name == name { - return &e + for i := range env { + if env[i].Name == name { + return &env[i] } } return nil @@ -114,6 +114,62 @@ func TestNewMutator(t *testing.T) { m := NewMutator(deploy) assert.NotNil(t, m) assert.Equal(t, deploy, m.current) + require.Len(t, m.plans, 1, "NewMutator must create exactly one initial plan") + require.NotNil(t, m.active, "active plan must be set") + assert.Equal(t, &m.plans[0], m.active, "active must point to the first plan") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + deploy := &appsv1.Deployment{} + m := NewMutator(deploy) + + m.BeginFeature() + require.Len(t, m.plans, 2, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[1], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 3) + assert.Equal(t, &m.plans[2], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + deploy := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + m := NewMutator(deploy) + + // Record mutations in the initial plan + m.EnsureReplicas(3) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1" + return nil + }) + + // Start a new feature and record different mutations + m.BeginFeature() + m.EnsureReplicas(5) + + // Initial plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].deploymentSpecEdits, 1, "initial plan should have one spec edit") + assert.Len(t, m.plans[0].containerEdits, 1, "initial plan should have one container edit") + assert.Len(t, m.plans[1].deploymentSpecEdits, 1, "second plan should have one spec edit") + assert.Empty(t, m.plans[1].containerEdits, "second plan should have no container edits") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + deploy := &appsv1.Deployment{} + m := NewMutator(deploy) + m.EnsureReplicas(3) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, int32(3), *deploy.Spec.Replicas) } func TestMutator_EditContainers(t *testing.T) { @@ -283,13 +339,10 @@ func TestMutator_InitContainers(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) - if deploy.Spec.Template.Spec.InitContainers[0].Image != newImage { - t.Errorf("expected image %s, got %s", newImage, deploy.Spec.Template.Spec.InitContainers[0].Image) - } + assert.Equal(t, newImage, deploy.Spec.Template.Spec.InitContainers[0].Image) } func TestMutator_ContainerPresence(t *testing.T) { @@ -315,21 +368,14 @@ func TestMutator_ContainerPresence(t *testing.T) { // Append m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if len(deploy.Spec.Template.Spec.Containers) != 2 { - t.Fatalf("expected 2 containers, got %d", len(deploy.Spec.Template.Spec.Containers)) - } - - if deploy.Spec.Template.Spec.Containers[0].Name != "app" || deploy.Spec.Template.Spec.Containers[0].Image != "app-new-image" { - t.Errorf("unexpected container at index 0: %+v", deploy.Spec.Template.Spec.Containers[0]) - } + err := m.Apply() + require.NoError(t, err) - if deploy.Spec.Template.Spec.Containers[1].Name != "new-container" || deploy.Spec.Template.Spec.Containers[1].Image != newImage { - t.Errorf("unexpected container at index 1: %+v", deploy.Spec.Template.Spec.Containers[1]) - } + require.Len(t, deploy.Spec.Template.Spec.Containers, 2) + assert.Equal(t, "app", deploy.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", deploy.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "new-container", deploy.Spec.Template.Spec.Containers[1].Name) + assert.Equal(t, newImage, deploy.Spec.Template.Spec.Containers[1].Image) } func TestMutator_InitContainerPresence(t *testing.T) { @@ -349,17 +395,11 @@ func TestMutator_InitContainerPresence(t *testing.T) { m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if len(deploy.Spec.Template.Spec.InitContainers) != 1 { - t.Fatalf("expected 1 init container, got %d", len(deploy.Spec.Template.Spec.InitContainers)) - } + err := m.Apply() + require.NoError(t, err) - if deploy.Spec.Template.Spec.InitContainers[0].Name != "init-2" { - t.Errorf("expected init-2, got %s", deploy.Spec.Template.Spec.InitContainers[0].Name) - } + require.Len(t, deploy.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-2", deploy.Spec.Template.Spec.InitContainers[0].Name) } func TestMutator_SelectorSnapshotSemantics(t *testing.T) { @@ -396,17 +436,11 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if deploy.Spec.Template.Spec.Containers[0].Name != appV2 { - t.Errorf("expected name %s, got %s", appV2, deploy.Spec.Template.Spec.Containers[0].Name) - } + err := m.Apply() + require.NoError(t, err) - if deploy.Spec.Template.Spec.Containers[0].Image != "app-image-updated" { - t.Errorf("expected image app-image-updated, got %s", deploy.Spec.Template.Spec.Containers[0].Image) - } + assert.Equal(t, appV2, deploy.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", deploy.Spec.Template.Spec.Containers[0].Image) } func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { @@ -431,18 +465,12 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { // Register presence later m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) // It should work because presence happens before edits in Apply() - if len(deploy.Spec.Template.Spec.Containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(deploy.Spec.Template.Spec.Containers)) - } - - if deploy.Spec.Template.Spec.Containers[0].Image != "edited-image" { - t.Errorf("expected edited-image, got %s", deploy.Spec.Template.Spec.Containers[0].Image) - } + require.Len(t, deploy.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "edited-image", deploy.Spec.Template.Spec.Containers[0].Image) } func TestMutator_NilSafety(t *testing.T) { @@ -484,7 +512,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(deploy) // Feature A: sets replicas to 2, image to v2 - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(2) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2" @@ -492,16 +520,15 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B: sets replicas to 3, image to v3 - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(3) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v3" return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) // Feature B should win assert.Equal(t, int32(3), *deploy.Spec.Replicas) @@ -546,9 +573,8 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) expectedOrder := []string{ "deploymentmeta", @@ -574,22 +600,21 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(deploy) // Feature A renames container - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - this should work! - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) assert.Equal(t, "app-v2", deploy.Spec.Template.Spec.Containers[0].Name) assert.Equal(t, "v2-image", deploy.Spec.Template.Spec.Containers[0].Image) @@ -629,9 +654,8 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) require.Len(t, deploy.Spec.Template.Spec.InitContainers, 1) assert.Equal(t, "init-1-renamed", deploy.Spec.Template.Spec.InitContainers[0].Name) diff --git a/pkg/primitives/deployment/resource_test.go b/pkg/primitives/deployment/resource_test.go index de0a71e7..47a5fdc2 100644 --- a/pkg/primitives/deployment/resource_test.go +++ b/pkg/primitives/deployment/resource_test.go @@ -129,7 +129,7 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Name: "feature-b", Feature: feature.NewResourceFeature("v1", nil).When(true), Mutate: func(m *Mutator) error { - // This should see image "v2" if beginFeature() is working correctly between mutations + // This should see image "v2" if BeginFeature() is working correctly between mutations m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { if e.Raw().Image == "v2" { e.Raw().Image = "v3" From 668d48466eeede6a5f1f869da9bda70ed88f47f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 02:57:55 +0000 Subject: [PATCH 18/32] Export BeginFeature to fix cross-package FeatureMutator interface The FeatureMutator interface in internal/generic used an unexported beginFeature() method, which meant primitive mutators in external packages (cronjob, deployment, configmap) could never satisfy the interface. The type assertion in ApplyMutations always returned false, so feature boundaries were silently skipped for all primitives. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/cronjob/mutator.go | 4 ++-- pkg/primitives/cronjob/mutator_test.go | 8 ++++---- pkg/primitives/deployment/mutator.go | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index 28e665b7..ac222cd5 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -58,8 +58,8 @@ func NewMutator(current *batchv1.CronJob) *Mutator { return m } -// beginFeature starts a new feature planning scope. -func (m *Mutator) beginFeature() { +// BeginFeature starts a new feature planning scope. +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go index 560a4962..db78fb2d 100644 --- a/pkg/primitives/cronjob/mutator_test.go +++ b/pkg/primitives/cronjob/mutator_test.go @@ -416,7 +416,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(cj) // Feature A - m.beginFeature() + m.BeginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("*/10 * * * *") return nil @@ -427,7 +427,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B - m.beginFeature() + m.BeginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("0 * * * *") return nil @@ -539,14 +539,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(cj) // Feature A renames container - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index bb752a3c..f1b8a136 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -64,6 +64,7 @@ func NewMutator(current *appsv1.Deployment) *Mutator { } // BeginFeature starts a new feature planning scope. All subsequent mutation +<<<<<<< HEAD // registrations will be grouped into this feature's plan until another // BeginFeature is called. // From 3f2221d280bbaa39ad60a5650738df3add81da1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:58:22 +0000 Subject: [PATCH 19/32] fix: preserve CronJob status in DefaultFieldApplicator and add tests Add generic.PreserveStatus call to DefaultFieldApplicator so that the live CronJob Status (LastScheduleTime, Active jobs, etc.) is not wiped during mutation. Add a dedicated test for status preservation and update documentation to describe the default field application behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/deployment/mutator.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/primitives/deployment/mutator.go b/pkg/primitives/deployment/mutator.go index f1b8a136..bb752a3c 100644 --- a/pkg/primitives/deployment/mutator.go +++ b/pkg/primitives/deployment/mutator.go @@ -64,7 +64,6 @@ func NewMutator(current *appsv1.Deployment) *Mutator { } // BeginFeature starts a new feature planning scope. All subsequent mutation -<<<<<<< HEAD // registrations will be grouped into this feature's plan until another // BeginFeature is called. // From 4fcf1268eee2695b59245841bebd673621844528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= Date: Mon, 23 Mar 2026 23:17:48 +0000 Subject: [PATCH 20/32] improve ai instructions (#44) * improve ai instructions --- .ai/review.md | 32 +++++++++++++++++++++++++++- .github/copilot-review-guidelines.md | 32 +++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.ai/review.md b/.ai/review.md index 1d908437..8ab17822 100644 --- a/.ai/review.md +++ b/.ai/review.md @@ -47,4 +47,34 @@ When reviewing code, apply the standards of a professional open source Go librar ## Documentation drift - If a code change affects behaviour described in `docs/`, `README.md`, or GoDoc, and that documentation was not updated, flag it. -- If a code change affects an example in `examples/`, and the example was not updated, flag it. \ No newline at end of file +- If a code change affects an example in `examples/`, and the example was not updated, flag it. + +## Existing pull request comments + +When doing a review, check the comments and discussions in the pull request. + +- Something already addressed in a previous comment should not be repeated in a new review unless it's a critical bug, or you are certain the outcome of the discussion did not properly address the issue. + +- Before raising a new comment, scan existing threads to check if the same issue has already been flagged. If it has, do not open a duplicate thread. + +- If a comment was explicitly marked as non-actionable — for example, the author replied with "won't fix", "by design", "out of scope", or a reviewer resolved it without a code change — treat it as a closed decision and do not re-raise it. Respect the outcome of the discussion even if you would have decided differently. + +- If a thread is still open and unresolved, you may reference it rather than opening a new comment ("This ties into the open discussion in [thread]"), but do not pile on with a redundant point. + +- If you believe a closed discussion reached the wrong conclusion and the issue is significant enough to re-raise, clearly acknowledge the prior decision and explain specifically why you think it needs revisiting. Do not silently re-raise it as if the prior discussion never happened. + +- Do not comment on style, formatting, or naming choices that were already discussed and accepted as-is by a reviewer or the author. + +## Reviewing completely in a single pass + +Aim to be exhaustive in a single review. Do not hold back comments with the intent of raising them in a later round — if something is worth flagging, flag it now. + +- When asked to review, go through the entire diff thoroughly before posting any comments. Do not do a shallow pass and leave obvious issues unraised because you were focused on one area. + +- Do not artificially limit the number of comments in a review. If there are 15 things worth flagging, raise all 15. A thorough first review is less disruptive than multiple thin rounds. + +- Do not save "minor" or "low priority" comments for later rounds. Include them in the first review, clearly labeled by severity if needed (e.g. nit, suggestion, blocking), so the author can address everything in one go. + +- After the author addresses your comments and requests a new review, focus only on what changed and any issues introduced by those changes. Do not use re-reviews as an opportunity to raise issues you could have caught in the first pass. + +- If you are uncertain whether something is an issue, first do a quick verification pass (e.g. read surrounding code, search for similar patterns, or check existing docs). Only leave a comment when you can point to a specific, evidenced concern, or clearly frame it as a question when you cannot confirm the intent. \ No newline at end of file diff --git a/.github/copilot-review-guidelines.md b/.github/copilot-review-guidelines.md index 1d908437..62d519ee 100644 --- a/.github/copilot-review-guidelines.md +++ b/.github/copilot-review-guidelines.md @@ -47,4 +47,34 @@ When reviewing code, apply the standards of a professional open source Go librar ## Documentation drift - If a code change affects behaviour described in `docs/`, `README.md`, or GoDoc, and that documentation was not updated, flag it. -- If a code change affects an example in `examples/`, and the example was not updated, flag it. \ No newline at end of file +- If a code change affects an example in `examples/`, and the example was not updated, flag it. + +## Existing pull request comments + +When doing a review, check the comments and discussions in the pull request. + +- Something already addressed in a previous comment should not be repeated in a new review unless it's a critical bug, or you are certain the outcome of the discussion did not properly address the issue. + +- Before raising a new comment, scan existing threads to check if the same issue has already been flagged. If it has, do not open a duplicate thread. + +- If a comment was explicitly marked as non-actionable — for example, the author replied with "won't fix", "by design", "out of scope", or a reviewer resolved it without a code change — treat it as a closed decision and do not re-raise it. Respect the outcome of the discussion even if you would have decided differently. + +- If a thread is still open and unresolved, you may reference it rather than opening a new comment ("This ties into the open discussion in [thread]"), but do not pile on with a redundant point. + +- If you believe a closed discussion reached the wrong conclusion and the issue is significant enough to re-raise, clearly acknowledge the prior decision and explain specifically why you think it needs revisiting. Do not silently re-raise it as if the prior discussion never happened. + +- Do not comment on style, formatting, or naming choices that were already discussed and accepted as-is by a reviewer or the author. + +## Reviewing completely in a single pass + +Aim to be exhaustive in a single review. Do not hold back comments with the intent of raising them in a later round — if something is worth flagging, flag it now. + +- When asked to review, go through the entire diff thoroughly before posting any comments. Do not do a shallow pass and leave obvious issues unraised because you were focused on one area. + +- Do not artificially limit the number of comments in a review. If there are 15 things worth flagging, raise all 15. A thorough first review is less disruptive than multiple thin rounds. + +- Do not save "minor" or "low priority" comments for later rounds. Include them in the first review, clearly labeled by severity if needed (e.g. nit, suggestion, blocking), so the author can address everything in one go. + +- After the author addresses your comments and requests a new review, focus only on what changed and any issues introduced by those changes. Do not use re-reviews as an opportunity to raise issues you could have caught in the first pass. + +- If you suspect something might be an issue, first do a quick verification pass (search, read surrounding code, check docs/tests). Only raise a comment when you can point to a specific, evidenced concern, or clearly frame it as a question when you do not have enough evidence. \ No newline at end of file From a870a043b9aac12a9528c34795b223056d0c9788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= Date: Tue, 24 Mar 2026 00:17:31 +0000 Subject: [PATCH 21/32] add markdown formatter, add helper for alive observedgeneration handling (#45) * add markdown formatter, add helper for alive observedgeneration handling * improve makefile --- .ai/base.md | 42 +++++-- .ai/review.md | 67 +++++++---- .github/copilot-instructions.md | 45 ++++++-- .github/copilot-review-guidelines.md | 67 +++++++---- .github/workflows/lint.yml | 3 +- .gitignore | 1 + .prettierrc | 13 +++ .tool-versions | 1 + Makefile | 40 ++++++- README.md | 42 ++++--- docs/component.md | 102 ++++++++++------ docs/primitives.md | 69 +++++++---- docs/primitives/configmap.md | 109 ++++++++++++------ docs/primitives/deployment.md | 103 +++++++++++------ examples/configmap-primitive/README.md | 15 ++- .../custom-resource-implementation/README.md | 52 ++++++--- examples/deployment-primitive/README.md | 23 ++-- pkg/component/concepts/converging.go | 39 ++++++- pkg/component/concepts/converging_test.go | 66 +++++++++++ pkg/primitives/deployment/handlers.go | 12 +- pkg/primitives/deployment/handlers_test.go | 53 ++++++++- 21 files changed, 709 insertions(+), 255 deletions(-) create mode 100644 .prettierrc diff --git a/.ai/base.md b/.ai/base.md index a9b4c0f6..2e6966a7 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -2,7 +2,9 @@ ## Project Context -This is a **professional open source Go framework** for building Kubernetes operators. Public APIs, documentation, and examples are a published contract consumed by other teams. Every change must meet the standard of a well-maintained open source library. +This is a **professional open source Go framework** for building Kubernetes operators. Public APIs, documentation, and +examples are a published contract consumed by other teams. Every change must meet the standard of a well-maintained open +source library. Module: `github.com/sourcehawk/operator-component-framework` @@ -10,7 +12,8 @@ Module: `github.com/sourcehawk/operator-component-framework` ## Before Making Changes -**Read before writing.** Always gather context from the actual source code and documentation before proposing or making changes. Do not reason from assumptions. +**Read before writing.** Always gather context from the actual source code and documentation before proposing or making +changes. Do not reason from assumptions. ### Documentation to read @@ -45,7 +48,8 @@ Controller └─ Kubernetes Object ``` -Changes at lower layers (primitives, editors, feature gating) ripple upward. Always trace the full impact before stopping. +Changes at lower layers (primitives, editors, feature gating) ripple upward. Always trace the full impact before +stopping. --- @@ -53,24 +57,29 @@ Changes at lower layers (primitives, editors, feature gating) ripple upward. Alw ### Clarify before implementing -If a prompt is insufficiently detailed to make a coherent and well-designed change — ambiguous scope, unclear intent, or missing context that would materially affect the approach — ask targeted followup questions before writing any code. A precise implementation of the wrong thing is worse than a short delay to align on the right one. +If a prompt is insufficiently detailed to make a coherent and well-designed change — ambiguous scope, unclear intent, or +missing context that would materially affect the approach — ask targeted followup questions before writing any code. A +precise implementation of the wrong thing is worse than a short delay to align on the right one. ### GoDoc -Every exported symbol has a GoDoc comment. Update it whenever you change the associated behaviour, signature, or semantics. GoDoc is part of the public API surface. +Every exported symbol has a GoDoc comment. Update it whenever you change the associated behaviour, signature, or +semantics. GoDoc is part of the public API surface. ### Documentation Update documentation in the **same response** as the code change — never leave them out of sync. | Code area changed | Documentation to update | -|---------------------------------------------------|-------------------------| +| ------------------------------------------------- | ----------------------- | | Component builder, reconciliation, status model | `docs/component.md` | | Primitives, field application, editors, selectors | `docs/primitives.md` | | Primitive implementations | `docs/primitives/*.md` | | Any `pkg/` export visible in the quick start | `README.md` | | Examples | `examples/*/README.md` | +When updating documentation in markdown files, make sure to run `make fmt-md` for consistent formatting. + ### Examples If you change a method signature, type name, or behaviour in `pkg/`, search `examples/` for usages and update them. @@ -107,19 +116,29 @@ To verify linting and formatting along with tests after making code changes use: make all ``` -**Tests encode intent, not implementation.** A test must assert what the code is *supposed to do*, not simply mirror what the code currently does. Never write or modify a test purely to make it pass — a failing test is evidence of a bug or a broken assumption, not a test to be fixed. +**Tests encode intent, not implementation.** A test must assert what the code is _supposed to do_, not simply mirror +what the code currently does. Never write or modify a test purely to make it pass — a failing test is evidence of a bug +or a broken assumption, not a test to be fixed. -**Prefer black-box tests.** Test behaviour through the public API by asserting inputs and outputs, without coupling tests to internal implementation details. White-box tests are appropriate when internal state must be verified and cannot be adequately observed through the public surface. +**Prefer black-box tests.** Test behaviour through the public API by asserting inputs and outputs, without coupling +tests to internal implementation details. White-box tests are appropriate when internal state must be verified and +cannot be adequately observed through the public surface. **Write new tests for new code paths.** When adding behaviour, write tests that cover: + - The primary success path - All meaningful failure and error paths - Boundary and edge cases specific to the logic being added -Updating an existing test is appropriate when the intended behaviour genuinely changed. If a test fails after a change, first determine whether the code or the test is wrong: +Updating an existing test is appropriate when the intended behaviour genuinely changed. If a test fails after a change, +first determine whether the code or the test is wrong: - If the intention behind the code change is clear and the test expectation is now stale, update the test. -- If the intention is unclear — that is, it is ambiguous whether the failing test reflects a regression or an intended change — read the implementation, GoDoc, surrounding code, and git history to infer it. If the inferred intention reveals that a method signature is imprecise or misleading, update the signature to express the intention correctly. If the intention remains genuinely ambiguous after analysis, **stop and ask** before touching either the code or the test. Do not guess. +- If the intention is unclear — that is, it is ambiguous whether the failing test reflects a regression or an intended + change — read the implementation, GoDoc, surrounding code, and git history to infer it. If the inferred intention + reveals that a method signature is imprecise or misleading, update the signature to express the intention correctly. + If the intention remains genuinely ambiguous after analysis, **stop and ask** before touching either the code or the + test. Do not guess. --- @@ -128,4 +147,5 @@ Updating an existing test is appropriate when the intended behaviour genuinely c - **Accurate** — verify every method name, field name, and import path against the source before writing examples. - **Complete** — document edge cases and non-obvious defaults alongside the main behaviour. - **Professional** — direct, precise prose. No filler phrases or vague hand-waving. -- **Consistent** — use the exact terminology from the source code: interface names, method names, status constant values. \ No newline at end of file +- **Consistent** — use the exact terminology from the source code: interface names, method names, status constant + values. diff --git a/.ai/review.md b/.ai/review.md index 8ab17822..aa555f1e 100644 --- a/.ai/review.md +++ b/.ai/review.md @@ -1,13 +1,18 @@ # Code Review Guidelines — Operator Component Framework -When reviewing code, apply the standards of a professional open source Go library. The goal is to surface anything that would be a quality or clarity problem once published — not to enforce style preferences. +When reviewing code, apply the standards of a professional open source Go library. The goal is to surface anything that +would be a quality or clarity problem once published — not to enforce style preferences. ## API clarity -- Exported names must be unambiguous in isolation, without reading the implementation. If a name requires a comment to interpret, the name is wrong. -- Prefer types that encode constraints over `bool` parameters, stringly-typed arguments, or functions with multiple parameters of the same type — these make call sites confusing and misuse easy. -- Any exported function that can meaningfully fail must return an `error`. Silent failure is not acceptable in a library. -- Interfaces should be as small as necessary. Flag any interface that bundles unrelated behaviour or that imposes unnecessary implementation burden. +- Exported names must be unambiguous in isolation, without reading the implementation. If a name requires a comment to + interpret, the name is wrong. +- Prefer types that encode constraints over `bool` parameters, stringly-typed arguments, or functions with multiple + parameters of the same type — these make call sites confusing and misuse easy. +- Any exported function that can meaningfully fail must return an `error`. Silent failure is not acceptable in a + library. +- Interfaces should be as small as necessary. Flag any interface that bundles unrelated behaviour or that imposes + unnecessary implementation burden. ## GoDoc quality @@ -18,7 +23,8 @@ When reviewing code, apply the standards of a professional open source Go librar ## Error quality -- Error messages must be specific and actionable. Flag generic messages such as `"operation failed"` or `"invalid input"`. +- Error messages must be specific and actionable. Flag generic messages such as `"operation failed"` or + `"invalid input"`. - Wrapped errors must add context that is not already present in the wrapped message. - Errors must not be silently discarded, even in cleanup paths. @@ -30,13 +36,15 @@ When reviewing code, apply the standards of a professional open source Go librar ## Complexity -- Flag functions that are doing more than one thing. A function that requires significant scrolling to read is a signal to decompose. +- Flag functions that are doing more than one thing. A function that requires significant scrolling to read is a signal + to decompose. - Flag abstractions with a single caller and no credible extension point — premature generality is a maintenance cost. - Flag unnecessary indirection: wrappers that add no behaviour, interfaces with a single permanent implementation. ## Consistency -- New code must follow the patterns already established in the package — builder pattern, error handling style, naming conventions. Inconsistency is a quality issue even when the code is technically correct. +- New code must follow the patterns already established in the package — builder pattern, error handling style, naming + conventions. Inconsistency is a quality issue even when the code is technically correct. ## Test quality @@ -46,35 +54,52 @@ When reviewing code, apply the standards of a professional open source Go librar ## Documentation drift -- If a code change affects behaviour described in `docs/`, `README.md`, or GoDoc, and that documentation was not updated, flag it. +- If a code change affects behaviour described in `docs/`, `README.md`, or GoDoc, and that documentation was not + updated, flag it. - If a code change affects an example in `examples/`, and the example was not updated, flag it. ## Existing pull request comments When doing a review, check the comments and discussions in the pull request. -- Something already addressed in a previous comment should not be repeated in a new review unless it's a critical bug, or you are certain the outcome of the discussion did not properly address the issue. +- Something already addressed in a previous comment should not be repeated in a new review unless it's a critical bug, + or you are certain the outcome of the discussion did not properly address the issue. -- Before raising a new comment, scan existing threads to check if the same issue has already been flagged. If it has, do not open a duplicate thread. +- Before raising a new comment, scan existing threads to check if the same issue has already been flagged. If it has, do + not open a duplicate thread. -- If a comment was explicitly marked as non-actionable — for example, the author replied with "won't fix", "by design", "out of scope", or a reviewer resolved it without a code change — treat it as a closed decision and do not re-raise it. Respect the outcome of the discussion even if you would have decided differently. +- If a comment was explicitly marked as non-actionable — for example, the author replied with "won't fix", "by design", + "out of scope", or a reviewer resolved it without a code change — treat it as a closed decision and do not re-raise + it. Respect the outcome of the discussion even if you would have decided differently. -- If a thread is still open and unresolved, you may reference it rather than opening a new comment ("This ties into the open discussion in [thread]"), but do not pile on with a redundant point. +- If a thread is still open and unresolved, you may reference it rather than opening a new comment ("This ties into the + open discussion in [thread]"), but do not pile on with a redundant point. -- If you believe a closed discussion reached the wrong conclusion and the issue is significant enough to re-raise, clearly acknowledge the prior decision and explain specifically why you think it needs revisiting. Do not silently re-raise it as if the prior discussion never happened. +- If you believe a closed discussion reached the wrong conclusion and the issue is significant enough to re-raise, + clearly acknowledge the prior decision and explain specifically why you think it needs revisiting. Do not silently + re-raise it as if the prior discussion never happened. -- Do not comment on style, formatting, or naming choices that were already discussed and accepted as-is by a reviewer or the author. +- Do not comment on style, formatting, or naming choices that were already discussed and accepted as-is by a reviewer or + the author. ## Reviewing completely in a single pass -Aim to be exhaustive in a single review. Do not hold back comments with the intent of raising them in a later round — if something is worth flagging, flag it now. +Aim to be exhaustive in a single review. Do not hold back comments with the intent of raising them in a later round — if +something is worth flagging, flag it now. -- When asked to review, go through the entire diff thoroughly before posting any comments. Do not do a shallow pass and leave obvious issues unraised because you were focused on one area. +- When asked to review, go through the entire diff thoroughly before posting any comments. Do not do a shallow pass and + leave obvious issues unraised because you were focused on one area. -- Do not artificially limit the number of comments in a review. If there are 15 things worth flagging, raise all 15. A thorough first review is less disruptive than multiple thin rounds. +- Do not artificially limit the number of comments in a review. If there are 15 things worth flagging, raise all 15. A + thorough first review is less disruptive than multiple thin rounds. -- Do not save "minor" or "low priority" comments for later rounds. Include them in the first review, clearly labeled by severity if needed (e.g. nit, suggestion, blocking), so the author can address everything in one go. +- Do not save "minor" or "low priority" comments for later rounds. Include them in the first review, clearly labeled by + severity if needed (e.g. nit, suggestion, blocking), so the author can address everything in one go. -- After the author addresses your comments and requests a new review, focus only on what changed and any issues introduced by those changes. Do not use re-reviews as an opportunity to raise issues you could have caught in the first pass. +- After the author addresses your comments and requests a new review, focus only on what changed and any issues + introduced by those changes. Do not use re-reviews as an opportunity to raise issues you could have caught in the + first pass. -- If you are uncertain whether something is an issue, first do a quick verification pass (e.g. read surrounding code, search for similar patterns, or check existing docs). Only leave a comment when you can point to a specific, evidenced concern, or clearly frame it as a question when you cannot confirm the intent. \ No newline at end of file +- If you are uncertain whether something is an issue, first do a quick verification pass (e.g. read surrounding code, + search for similar patterns, or check existing docs). Only leave a comment when you can point to a specific, evidenced + concern, or clearly frame it as a question when you cannot confirm the intent. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0ca93292..4ca4506a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,9 @@ ## Project Context -This is a **professional open source Go framework** for building Kubernetes operators. Public APIs, documentation, and examples are a published contract consumed by other teams. Every change must meet the standard of a well-maintained open source library. +This is a **professional open source Go framework** for building Kubernetes operators. Public APIs, documentation, and +examples are a published contract consumed by other teams. Every change must meet the standard of a well-maintained open +source library. Module: `github.com/sourcehawk/operator-component-framework` @@ -10,7 +12,8 @@ Module: `github.com/sourcehawk/operator-component-framework` ## Before Making Changes -**Read before writing.** Always gather context from the actual source code and documentation before proposing or making changes. Do not reason from assumptions. +**Read before writing.** Always gather context from the actual source code and documentation before proposing or making +changes. Do not reason from assumptions. ### Documentation to read @@ -45,7 +48,8 @@ Controller └─ Kubernetes Object ``` -Changes at lower layers (primitives, editors, feature gating) ripple upward. Always trace the full impact before stopping. +Changes at lower layers (primitives, editors, feature gating) ripple upward. Always trace the full impact before +stopping. --- @@ -53,24 +57,29 @@ Changes at lower layers (primitives, editors, feature gating) ripple upward. Alw ### Clarify before implementing -If a prompt is insufficiently detailed to make a coherent and well-designed change — ambiguous scope, unclear intent, or missing context that would materially affect the approach — ask targeted followup questions before writing any code. A precise implementation of the wrong thing is worse than a short delay to align on the right one. +If a prompt is insufficiently detailed to make a coherent and well-designed change — ambiguous scope, unclear intent, or +missing context that would materially affect the approach — ask targeted followup questions before writing any code. A +precise implementation of the wrong thing is worse than a short delay to align on the right one. ### GoDoc -Every exported symbol has a GoDoc comment. Update it whenever you change the associated behaviour, signature, or semantics. GoDoc is part of the public API surface. +Every exported symbol has a GoDoc comment. Update it whenever you change the associated behaviour, signature, or +semantics. GoDoc is part of the public API surface. ### Documentation Update documentation in the **same response** as the code change — never leave them out of sync. | Code area changed | Documentation to update | -|---------------------------------------------------|-------------------------| +| ------------------------------------------------- | ----------------------- | | Component builder, reconciliation, status model | `docs/component.md` | | Primitives, field application, editors, selectors | `docs/primitives.md` | | Primitive implementations | `docs/primitives/*.md` | | Any `pkg/` export visible in the quick start | `README.md` | | Examples | `examples/*/README.md` | +When updating documentation in markdown files, make sure to run `make fmt-md` for consistent formatting. + ### Examples If you change a method signature, type name, or behaviour in `pkg/`, search `examples/` for usages and update them. @@ -107,19 +116,29 @@ To verify linting and formatting along with tests after making code changes use: make all ``` -**Tests encode intent, not implementation.** A test must assert what the code is *supposed to do*, not simply mirror what the code currently does. Never write or modify a test purely to make it pass — a failing test is evidence of a bug or a broken assumption, not a test to be fixed. +**Tests encode intent, not implementation.** A test must assert what the code is _supposed to do_, not simply mirror +what the code currently does. Never write or modify a test purely to make it pass — a failing test is evidence of a bug +or a broken assumption, not a test to be fixed. -**Prefer black-box tests.** Test behaviour through the public API by asserting inputs and outputs, without coupling tests to internal implementation details. White-box tests are appropriate when internal state must be verified and cannot be adequately observed through the public surface. +**Prefer black-box tests.** Test behaviour through the public API by asserting inputs and outputs, without coupling +tests to internal implementation details. White-box tests are appropriate when internal state must be verified and +cannot be adequately observed through the public surface. **Write new tests for new code paths.** When adding behaviour, write tests that cover: + - The primary success path - All meaningful failure and error paths - Boundary and edge cases specific to the logic being added -Updating an existing test is appropriate when the intended behaviour genuinely changed. If a test fails after a change, first determine whether the code or the test is wrong: +Updating an existing test is appropriate when the intended behaviour genuinely changed. If a test fails after a change, +first determine whether the code or the test is wrong: - If the intention behind the code change is clear and the test expectation is now stale, update the test. -- If the intention is unclear — that is, it is ambiguous whether the failing test reflects a regression or an intended change — read the implementation, GoDoc, surrounding code, and git history to infer it. If the inferred intention reveals that a method signature is imprecise or misleading, update the signature to express the intention correctly. If the intention remains genuinely ambiguous after analysis, **stop and ask** before touching either the code or the test. Do not guess. +- If the intention is unclear — that is, it is ambiguous whether the failing test reflects a regression or an intended + change — read the implementation, GoDoc, surrounding code, and git history to infer it. If the inferred intention + reveals that a method signature is imprecise or misleading, update the signature to express the intention correctly. + If the intention remains genuinely ambiguous after analysis, **stop and ask** before touching either the code or the + test. Do not guess. --- @@ -128,10 +147,12 @@ Updating an existing test is appropriate when the intended behaviour genuinely c - **Accurate** — verify every method name, field name, and import path against the source before writing examples. - **Complete** — document edge cases and non-obvious defaults alongside the main behaviour. - **Professional** — direct, precise prose. No filler phrases or vague hand-waving. -- **Consistent** — use the exact terminology from the source code: interface names, method names, status constant values. +- **Consistent** — use the exact terminology from the source code: interface names, method names, status constant + values. --- ## Code Review -When reviewing pull requests, apply the standards in [`.github/copilot-review-guidelines.md`](copilot-review-guidelines.md). +When reviewing pull requests, apply the standards in +[`.github/copilot-review-guidelines.md`](copilot-review-guidelines.md). diff --git a/.github/copilot-review-guidelines.md b/.github/copilot-review-guidelines.md index 62d519ee..aa555f1e 100644 --- a/.github/copilot-review-guidelines.md +++ b/.github/copilot-review-guidelines.md @@ -1,13 +1,18 @@ # Code Review Guidelines — Operator Component Framework -When reviewing code, apply the standards of a professional open source Go library. The goal is to surface anything that would be a quality or clarity problem once published — not to enforce style preferences. +When reviewing code, apply the standards of a professional open source Go library. The goal is to surface anything that +would be a quality or clarity problem once published — not to enforce style preferences. ## API clarity -- Exported names must be unambiguous in isolation, without reading the implementation. If a name requires a comment to interpret, the name is wrong. -- Prefer types that encode constraints over `bool` parameters, stringly-typed arguments, or functions with multiple parameters of the same type — these make call sites confusing and misuse easy. -- Any exported function that can meaningfully fail must return an `error`. Silent failure is not acceptable in a library. -- Interfaces should be as small as necessary. Flag any interface that bundles unrelated behaviour or that imposes unnecessary implementation burden. +- Exported names must be unambiguous in isolation, without reading the implementation. If a name requires a comment to + interpret, the name is wrong. +- Prefer types that encode constraints over `bool` parameters, stringly-typed arguments, or functions with multiple + parameters of the same type — these make call sites confusing and misuse easy. +- Any exported function that can meaningfully fail must return an `error`. Silent failure is not acceptable in a + library. +- Interfaces should be as small as necessary. Flag any interface that bundles unrelated behaviour or that imposes + unnecessary implementation burden. ## GoDoc quality @@ -18,7 +23,8 @@ When reviewing code, apply the standards of a professional open source Go librar ## Error quality -- Error messages must be specific and actionable. Flag generic messages such as `"operation failed"` or `"invalid input"`. +- Error messages must be specific and actionable. Flag generic messages such as `"operation failed"` or + `"invalid input"`. - Wrapped errors must add context that is not already present in the wrapped message. - Errors must not be silently discarded, even in cleanup paths. @@ -30,13 +36,15 @@ When reviewing code, apply the standards of a professional open source Go librar ## Complexity -- Flag functions that are doing more than one thing. A function that requires significant scrolling to read is a signal to decompose. +- Flag functions that are doing more than one thing. A function that requires significant scrolling to read is a signal + to decompose. - Flag abstractions with a single caller and no credible extension point — premature generality is a maintenance cost. - Flag unnecessary indirection: wrappers that add no behaviour, interfaces with a single permanent implementation. ## Consistency -- New code must follow the patterns already established in the package — builder pattern, error handling style, naming conventions. Inconsistency is a quality issue even when the code is technically correct. +- New code must follow the patterns already established in the package — builder pattern, error handling style, naming + conventions. Inconsistency is a quality issue even when the code is technically correct. ## Test quality @@ -46,35 +54,52 @@ When reviewing code, apply the standards of a professional open source Go librar ## Documentation drift -- If a code change affects behaviour described in `docs/`, `README.md`, or GoDoc, and that documentation was not updated, flag it. +- If a code change affects behaviour described in `docs/`, `README.md`, or GoDoc, and that documentation was not + updated, flag it. - If a code change affects an example in `examples/`, and the example was not updated, flag it. ## Existing pull request comments When doing a review, check the comments and discussions in the pull request. -- Something already addressed in a previous comment should not be repeated in a new review unless it's a critical bug, or you are certain the outcome of the discussion did not properly address the issue. +- Something already addressed in a previous comment should not be repeated in a new review unless it's a critical bug, + or you are certain the outcome of the discussion did not properly address the issue. -- Before raising a new comment, scan existing threads to check if the same issue has already been flagged. If it has, do not open a duplicate thread. +- Before raising a new comment, scan existing threads to check if the same issue has already been flagged. If it has, do + not open a duplicate thread. -- If a comment was explicitly marked as non-actionable — for example, the author replied with "won't fix", "by design", "out of scope", or a reviewer resolved it without a code change — treat it as a closed decision and do not re-raise it. Respect the outcome of the discussion even if you would have decided differently. +- If a comment was explicitly marked as non-actionable — for example, the author replied with "won't fix", "by design", + "out of scope", or a reviewer resolved it without a code change — treat it as a closed decision and do not re-raise + it. Respect the outcome of the discussion even if you would have decided differently. -- If a thread is still open and unresolved, you may reference it rather than opening a new comment ("This ties into the open discussion in [thread]"), but do not pile on with a redundant point. +- If a thread is still open and unresolved, you may reference it rather than opening a new comment ("This ties into the + open discussion in [thread]"), but do not pile on with a redundant point. -- If you believe a closed discussion reached the wrong conclusion and the issue is significant enough to re-raise, clearly acknowledge the prior decision and explain specifically why you think it needs revisiting. Do not silently re-raise it as if the prior discussion never happened. +- If you believe a closed discussion reached the wrong conclusion and the issue is significant enough to re-raise, + clearly acknowledge the prior decision and explain specifically why you think it needs revisiting. Do not silently + re-raise it as if the prior discussion never happened. -- Do not comment on style, formatting, or naming choices that were already discussed and accepted as-is by a reviewer or the author. +- Do not comment on style, formatting, or naming choices that were already discussed and accepted as-is by a reviewer or + the author. ## Reviewing completely in a single pass -Aim to be exhaustive in a single review. Do not hold back comments with the intent of raising them in a later round — if something is worth flagging, flag it now. +Aim to be exhaustive in a single review. Do not hold back comments with the intent of raising them in a later round — if +something is worth flagging, flag it now. -- When asked to review, go through the entire diff thoroughly before posting any comments. Do not do a shallow pass and leave obvious issues unraised because you were focused on one area. +- When asked to review, go through the entire diff thoroughly before posting any comments. Do not do a shallow pass and + leave obvious issues unraised because you were focused on one area. -- Do not artificially limit the number of comments in a review. If there are 15 things worth flagging, raise all 15. A thorough first review is less disruptive than multiple thin rounds. +- Do not artificially limit the number of comments in a review. If there are 15 things worth flagging, raise all 15. A + thorough first review is less disruptive than multiple thin rounds. -- Do not save "minor" or "low priority" comments for later rounds. Include them in the first review, clearly labeled by severity if needed (e.g. nit, suggestion, blocking), so the author can address everything in one go. +- Do not save "minor" or "low priority" comments for later rounds. Include them in the first review, clearly labeled by + severity if needed (e.g. nit, suggestion, blocking), so the author can address everything in one go. -- After the author addresses your comments and requests a new review, focus only on what changed and any issues introduced by those changes. Do not use re-reviews as an opportunity to raise issues you could have caught in the first pass. +- After the author addresses your comments and requests a new review, focus only on what changed and any issues + introduced by those changes. Do not use re-reviews as an opportunity to raise issues you could have caught in the + first pass. -- If you suspect something might be an issue, first do a quick verification pass (search, read surrounding code, check docs/tests). Only raise a comment when you can point to a specific, evidenced concern, or clearly frame it as a question when you do not have enough evidence. \ No newline at end of file +- If you are uncertain whether something is an issue, first do a quick verification pass (e.g. read surrounding code, + search for similar patterns, or check existing docs). Only leave a comment when you can point to a specific, evidenced + concern, or clearly frame it as a question when you cannot confirm the intent. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 54752a35..d6f29e1a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,6 +25,7 @@ jobs: with: only: | golangci-lint + nodejs - name: Run linter - run: golangci-lint run \ No newline at end of file + run: make lint \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4fcaf842..b39f0d58 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ go.work.sum # Common local tools bin/ +node_modules/ dist/ tmp/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..f8d50ae4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "overrides": [ + { + "files": "*.md", + "options": { + "parser": "markdown", + "proseWrap": "always", + "tabWidth": 2, + "printWidth": 120 + } + } + ] +} \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index a1f05654..6edfef3b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ golang 1.26.1 golangci-lint 2.11.2 +nodejs 25.1.0 diff --git a/Makefile b/Makefile index 78dcae45..29424227 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all -all: fmt lint test build-examples +all: fmt fmt-md lint test build-examples ##@ General @@ -25,7 +25,9 @@ LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): mkdir -p $(LOCALBIN) +PRETTIER ?= $(LOCALBIN)/prettier ENVTEST ?= $(LOCALBIN)/setup-envtest +PRETTIER_VERSION ?= 3.8.1 #ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') @@ -66,11 +68,39 @@ ai-instructions: ## Generate all AI instruction files from source templates in . ##@ Development .PHONY: fmt -fmt: - go fmt $(shell go list ./... | grep -v /examples/) +fmt: fmt-go fmt-md ## Run all formatting in the project -.PHONY: lint -lint: + +.PHONY: fmt-go +fmt-go: ## Format Go source files. + go fmt ./... + + +.PHONY: fmt-md +fmt-md: prettier ## Format Markdown files. + $(PRETTIER) --write '**/*.md' --ignore-path .gitignore + +.PHONY: prettier +prettier: $(PRETTIER) ## Download prettier locally if necessary. +$(PRETTIER): $(LOCALBIN) + @[ -f $(PRETTIER) ] || { \ + set -e ; \ + echo "Installing prettier@$(PRETTIER_VERSION)..." ; \ + npm install --prefix $(LOCALBIN)/prettier-pkg prettier@$(PRETTIER_VERSION) && \ + printf '#!/bin/sh\nexec node "$(LOCALBIN)/prettier-pkg/node_modules/.bin/prettier" "$$@"\n' > $(PRETTIER) && \ + chmod +x $(PRETTIER) ; \ + } + +.PHONY: lint ## Run all linters +lint: lint-go lint-md + +.PHONY: lint-md +lint-md: prettier ## Check Markdown files are formatted. + $(PRETTIER) --check '**/*.md' --ignore-path .gitignore + + +.PHONY: lint-go ## Lint go files. +lint-go: golangci-lint run .PHONY: test diff --git a/README.md b/README.md index 5c8e8208..d26b5be4 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,25 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/sourcehawk/operator-component-framework)](https://goreportcard.com/report/github.com/sourcehawk/operator-component-framework) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -A Go framework for building highly maintainable Kubernetes operators using a behavioral component model and version-gated feature mutations. +A Go framework for building highly maintainable Kubernetes operators using a behavioral component model and +version-gated feature mutations. --- ## Overview -Kubernetes operators tend to accumulate complexity over time: reconciliation functions grow large, lifecycle logic is duplicated across resources, status reporting becomes inconsistent, and version-compatibility code gets tangled into orchestration. The Operator Component Framework addresses these problems through a clear layered architecture. +Kubernetes operators tend to accumulate complexity over time: reconciliation functions grow large, lifecycle logic is +duplicated across resources, status reporting becomes inconsistent, and version-compatibility code gets tangled into +orchestration. The Operator Component Framework addresses these problems through a clear layered architecture. The framework organizes operator logic into three composable layers: -- **Components** — logical feature units that reconcile multiple resources together and report a single user-facing condition. -- **Resource Primitives** — reusable, type-safe wrappers for individual Kubernetes objects with built-in lifecycle semantics. -- **Feature Mutations** — composable, version-gated modifications that keep baseline resource definitions clean while managing optional and historical behavior explicitly. +- **Components** — logical feature units that reconcile multiple resources together and report a single user-facing + condition. +- **Resource Primitives** — reusable, type-safe wrappers for individual Kubernetes objects with built-in lifecycle + semantics. +- **Feature Mutations** — composable, version-gated modifications that keep baseline resource definitions clean while + managing optional and historical behavior explicitly. ## Mental Model @@ -28,7 +34,7 @@ Controller ``` | Layer | Responsibility | -|------------------------|-----------------------------------------------------------------------------------------| +| ---------------------- | --------------------------------------------------------------------------------------- | | **Controller** | Determines which components should exist; orchestrates reconciliation at a high level | | **Component** | Represents one logical feature; reconciles its resources and reports a single condition | | **Resource Primitive** | Encapsulates desired state and lifecycle behavior for a single Kubernetes object | @@ -42,7 +48,8 @@ Controller - **Suspension handling** with configurable behavior — scale to zero, delete, or custom logic - **Version-gated mutations** to apply backward-compatibility patches only when needed - **Composable mutation layers** that stack without interfering with each other -- **Built-in lifecycle interfaces** (`Alive`, `Graceful`, `Suspendable`, `Completable`, `Operational`, `DataExtractable`) covering the full range of Kubernetes workload types +- **Built-in lifecycle interfaces** (`Alive`, `Graceful`, `Suspendable`, `Completable`, `Operational`, + `DataExtractable`) covering the full range of Kubernetes workload types - **Typed mutation editors** for kubernetes resource primitives - **Metrics and event recording** integrations out of the box @@ -56,7 +63,8 @@ Requires Go 1.21+ and a project using [controller-runtime](https://github.com/ku ## Quick Start -The following example builds a component that manages a single `Deployment`, with an optional tracing feature applied as a mutation. +The following example builds a component that manages a single `Deployment`, with an optional tracing feature applied as +a mutation. ```go import ( @@ -135,7 +143,8 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (re ## Feature Mutations -Mutations decouple version-specific or feature-gated logic from the baseline resource definition. A mutation declares a condition under which it applies and a function that modifies the resource. +Mutations decouple version-specific or feature-gated logic from the baseline resource definition. A mutation declares a +condition under which it applies and a function that modifies the resource. ```go import ( @@ -161,14 +170,15 @@ func TracingFeature(version string, enabled bool) deployment.Mutation { } ``` -Mutations are applied in registration order. Each mutation is independent — multiple mutations can target the same resource without interfering with each other, and the framework guarantees a consistent application sequence. +Mutations are applied in registration order. Each mutation is independent — multiple mutations can target the same +resource without interfering with each other, and the framework guarantees a consistent application sequence. ## Resource Lifecycle Interfaces Resource primitives implement behavioral interfaces that the component layer uses for status aggregation: | Interface | Behavior | Example resources | -|-------------------|---------------------------------------------------|-----------------------------------------| +| ----------------- | ------------------------------------------------- | --------------------------------------- | | `Alive` | Observable health with rolling-update awareness | Deployments, StatefulSets, DaemonSets | | `Graceful` | Time-bounded convergence with degradation | Workloads with slow rollouts | | `Suspendable` | Controlled deactivation (scale to zero or delete) | Workloads, task primitives | @@ -193,14 +203,15 @@ type Resource interface { } ``` -Optionally implement any of the lifecycle interfaces (`Alive`, `Suspendable`, etc.) to participate in condition aggregation. +Optionally implement any of the lifecycle interfaces (`Alive`, `Suspendable`, etc.) to participate in condition +aggregation. See the [examples directory](examples/) for complete implementations. ## Documentation | Document | Description | -|-------------------------------------------------------|----------------------------------------------------------------------| +| ----------------------------------------------------- | -------------------------------------------------------------------- | | [Component Framework](docs/component.md) | Reconciliation lifecycle, condition model, grace periods, suspension | | [Resource Primitives](docs/primitives.md) | Primitive categories, field application pipeline, mutation system | | [Deployment Primitive](docs/primitives/deployment.md) | Deployment-specific mutation ordering and editors | @@ -210,7 +221,7 @@ See the [examples directory](examples/) for complete implementations. The [examples directory](examples/) contains runnable, end-to-end implementations: | Example | Description | -|------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`deployment-primitive`](examples/deployment-primitive/) | Core Deployment primitive: mutations, flavors, suspension, data extraction | | [`custom-resource-implementation`](examples/custom-resource-implementation/) | Full custom resource wrapper implementing lifecycle interfaces and version-gated mutations | @@ -254,7 +265,8 @@ Contributions are welcome. Please open an issue to discuss significant changes b 3. Commit your changes 4. Open a pull request against `main` -All new code should include tests. The project uses [Ginkgo](https://github.com/onsi/ginkgo) and [Gomega](https://github.com/onsi/gomega) for testing. +All new code should include tests. The project uses [Ginkgo](https://github.com/onsi/ginkgo) and +[Gomega](https://github.com/onsi/gomega) for testing. ```bash go test ./... diff --git a/docs/component.md b/docs/component.md index c74db12f..5d67676e 100644 --- a/docs/component.md +++ b/docs/component.md @@ -1,8 +1,10 @@ # Component System -The `component` package provides a structured way to manage logical features in a Kubernetes operator by grouping related resources into **Components**. +The `component` package provides a structured way to manage logical features in a Kubernetes operator by grouping +related resources into **Components**. -A Component acts as a single behavioral unit: it reconciles multiple resources, manages their shared lifecycle, and reports their aggregate health through one condition on the owner CRD. +A Component acts as a single behavioral unit: it reconciles multiple resources, manages their shared lifecycle, and +reports their aggregate health through one condition on the owner CRD. ## Why Components Exist @@ -12,11 +14,13 @@ In complex operators, reconciliation logic tends to become fragmented: - Lifecycle logic (rollouts, suspension, degradation) is reimplemented for every feature - Status reporting varies across features, making it hard to reason about overall health -Components address this by providing a consistent pattern: one component per logical feature, one condition per component. +Components address this by providing a consistent pattern: one component per logical feature, one condition per +component. ## Building a Component -Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle flags, then produces an immutable `Component` ready for reconciliation. +Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle +flags, then produces an immutable `Component` ready for reconciliation. ```go comp, err := component.NewComponentBuilder(). @@ -38,7 +42,7 @@ if err != nil { Each resource is registered with a `ResourceOptions` struct that controls how the component interacts with it: | Option | Behavior | -|------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `ResourceOptions{}` (default) | **Managed** — created or updated; health contributes to condition | | `ResourceOptions{ReadOnly: true}` | **Read-only** — fetched but never modified; health still contributes | | `ResourceOptions{Delete: true}` | **Delete-only** — removed from the cluster if present; does not contribute to health | @@ -48,46 +52,55 @@ Each resource is registered with a `ResourceOptions` struct that controls how th `comp.Reconcile(ctx, recCtx)` runs a six-phase process on every call: -**Phase 1 — Suspension check** -If the component is marked suspended, it calls `Suspend()` on all managed resources that support suspension (create/update resources, not read-only ones), updates the condition, then processes any pending deletions and returns. The remaining phases are skipped. +**Phase 1 — Suspension check** If the component is marked suspended, it calls `Suspend()` on all managed resources that +support suspension (create/update resources, not read-only ones), updates the condition, then processes any pending +deletions and returns. The remaining phases are skipped. -**Phase 2 — Resource synchronization** -All managed resources are created or updated to match their desired state. Each resource gets a controller owner reference pointing to the owner CRD, unless the resource is cluster-scoped and the owner is namespace-scoped — in that case the reference is automatically skipped (see [Cluster-Scoped Resources](#cluster-scoped-resources)). +**Phase 2 — Resource synchronization** All managed resources are created or updated to match their desired state. Each +resource gets a controller owner reference pointing to the owner CRD, unless the resource is cluster-scoped and the +owner is namespace-scoped — in that case the reference is automatically skipped (see +[Cluster-Scoped Resources](#cluster-scoped-resources)). -**Phase 3 — Read-only resource fetching** -Read-only resources are fetched from the cluster so their current state is available for health evaluation. +**Phase 3 — Read-only resource fetching** Read-only resources are fetched from the cluster so their current state is +available for health evaluation. -**Phase 4 — Data extraction** -Any resource implementing `DataExtractable` has `ExtractData()` called to harvest data from the synchronized cluster state before condition evaluation. +**Phase 4 — Data extraction** Any resource implementing `DataExtractable` has `ExtractData()` called to harvest data +from the synchronized cluster state before condition evaluation. -**Phase 5 — Status aggregation and condition update** -The health of each resource is collected, the grace period is consulted, and a single aggregate condition is written to the owner object's status. +**Phase 5 — Status aggregation and condition update** The health of each resource is collected, the grace period is +consulted, and a single aggregate condition is written to the owner object's status. -**Phase 6 — Resource deletion** -Resources registered for deletion are removed from the cluster. +**Phase 6 — Resource deletion** Resources registered for deletion are removed from the cluster. ## Cluster-Scoped Resources -When a component manages cluster-scoped resources (e.g., `ClusterRole`, `PersistentVolume`) and the owner CRD is namespace-scoped, the framework **automatically skips** setting a controller owner reference on those resources. This is a Kubernetes API constraint — a namespace-scoped object cannot own a cluster-scoped object. +When a component manages cluster-scoped resources (e.g., `ClusterRole`, `PersistentVolume`) and the owner CRD is +namespace-scoped, the framework **automatically skips** setting a controller owner reference on those resources. This is +a Kubernetes API constraint — a namespace-scoped object cannot own a cluster-scoped object. -The scope of both the owner and the resource is determined at reconcile time using the cluster's REST mapper. No configuration is needed; the framework detects the incompatibility and logs an info-level message. +The scope of both the owner and the resource is determined at reconcile time using the cluster's REST mapper. No +configuration is needed; the framework detects the incompatibility and logs an info-level message. -**Garbage collection caveat:** Without an owner reference, cluster-scoped resources are **not** automatically deleted when the owner is removed. To ensure cleanup, either: -- Register the resource with `ResourceOptions{Delete: true}` so it is removed during reconciliation when no longer needed. +**Garbage collection caveat:** Without an owner reference, cluster-scoped resources are **not** automatically deleted +when the owner is removed. To ensure cleanup, either: + +- Register the resource with `ResourceOptions{Delete: true}` so it is removed during reconciliation when no longer + needed. - Use a finalizer on the owner CRD to clean up cluster-scoped resources before the owner is deleted. If the owner CRD is itself cluster-scoped, owner references are set normally on all resources regardless of their scope. ## Status Model -The status values a component reports depend on which lifecycle interfaces its resources implement. The component aggregates across all registered resources and surfaces the most critical state. +The status values a component reports depend on which lifecycle interfaces its resources implement. The component +aggregates across all registered resources and surfaces the most critical state. ### Alive Resources (`Alive` interface) Reported by long-running workloads (Deployments, StatefulSets, DaemonSets): | State | Meaning | -|------------|----------------------------------------------------------| +| ---------- | -------------------------------------------------------- | | `Healthy` | The resource has reached its desired state | | `Creating` | The resource is being provisioned for the first time | | `Updating` | The resource is being modified with new configuration | @@ -99,7 +112,7 @@ Reported by long-running workloads (Deployments, StatefulSets, DaemonSets): Reported by run-to-completion resources (Jobs, tasks): | State | Meaning | -|---------------|-------------------------------------| +| ------------- | ----------------------------------- | | `Completed` | The resource finished successfully | | `TaskRunning` | The resource is currently executing | | `TaskPending` | The resource is waiting to start | @@ -110,7 +123,7 @@ Reported by run-to-completion resources (Jobs, tasks): Reported by integration resources whose readiness depends on external systems (Services, Ingresses, Gateways, CronJobs): | State | Meaning | -|--------------------|---------------------------------------------------| +| ------------------ | ------------------------------------------------- | | `Operational` | The resource is fully operational | | `OperationPending` | The resource is waiting on an external dependency | | `OperationFailing` | The resource failed to reach an operational state | @@ -121,10 +134,11 @@ Resources that implement none of the above interfaces are considered ready as lo ### Grace States -When a component has a grace period configured and a `Graceful` resource has not reached its target state within that period, the `Graceful` interface determines the post-expiry severity: +When a component has a grace period configured and a `Graceful` resource has not reached its target state within that +period, the `Graceful` interface determines the post-expiry severity: | State | Meaning | -|------------|------------------------------------------------------------------------------------| +| ---------- | ---------------------------------------------------------------------------------- | | `Healthy` | The resource is healthy (grace period expired without issue) | | `Degraded` | The resource is partially functional or convergence is taking longer than expected | | `Down` | The resource is completely non-functional | @@ -134,7 +148,7 @@ When a component has a grace period configured and a `Graceful` resource has not Reported during intentional deactivation: | State | Meaning | -|---------------------|--------------------------------------------------------| +| ------------------- | ------------------------------------------------------ | | `PendingSuspension` | Suspension is acknowledged but has not started | | `Suspending` | Resources are actively being scaled down or cleaned up | | `Suspended` | All resources have reached their suspended state | @@ -145,12 +159,14 @@ When aggregating across multiple resources, the most critical state wins: 1. `Error` / `Down` / `Degraded` — something is wrong 2. Suspension states — the component is intentionally inactive -3. Converging states (`Creating`, `Updating`, `Scaling`, `TaskRunning`, `TaskPending`, `OperationPending`) — the component is progressing +3. Converging states (`Creating`, `Updating`, `Scaling`, `TaskRunning`, `TaskPending`, `OperationPending`) — the + component is progressing 4. `Healthy` / `Completed` / `Operational` — all resources are in their target state ## Grace Period -The grace period defines how long a component may remain in a converging state (`Creating`, `Updating`, `Scaling`) before transitioning to `Degraded` or `Down`. +The grace period defines how long a component may remain in a converging state (`Creating`, `Updating`, `Scaling`) +before transitioning to `Degraded` or `Down`. ```go component.NewComponentBuilder(). @@ -158,22 +174,27 @@ component.NewComponentBuilder(). // ... ``` -During the grace period the component reports its real converging state, not a failure. After the period expires, if the component is still not `Ready`, the framework escalates to `Degraded` or `Down` based on resource health. +During the grace period the component reports its real converging state, not a failure. After the period expires, if the +component is still not `Ready`, the framework escalates to `Degraded` or `Down` based on resource health. This prevents spurious failure alerts during normal operations like rolling updates. ## Suspension Lifecycle -Suspension allows a component to be intentionally deactivated without deleting its configuration. When `Suspend(true)` is set on the builder: +Suspension allows a component to be intentionally deactivated without deleting its configuration. When `Suspend(true)` +is set on the builder: 1. The component calls `Suspend()` on all `Suspendable` resources. 2. Each resource performs its suspension behavior — typically scaling to zero replicas. 3. The component polls `SuspensionStatus()` on each resource. 4. Once all resources report `Suspended`, the condition transitions to `Suspended`. -Resources that do not yet exist in the cluster are created in their suspended state (with suspension mutations already applied). For example, a Deployment is created with zero replicas. This ensures the resource is immediately available when suspension ends. +Resources that do not yet exist in the cluster are created in their suspended state (with suspension mutations already +applied). For example, a Deployment is created with zero replicas. This ensures the resource is immediately available +when suspension ends. -Resources with `DeleteOnSuspend` enabled are **not** created if they are already absent — their absence is treated as already suspended. This avoids a create→delete churn loop on every reconcile while the component remains suspended. +Resources with `DeleteOnSuspend` enabled are **not** created if they are already absent — their absence is treated as +already suspended. This avoids a create→delete churn loop on every reconcile while the component remains suspended. Resources that are not `Suspendable` are left in place. @@ -196,10 +217,15 @@ Dependencies are passed explicitly so components remain testable and decoupled f ## Best Practices -**Keep controllers thin.** The controller's job is to fetch the owner CRD, decide which components should exist, and call `Reconcile` on each. Resource-level logic belongs in the component and its primitives. +**Keep controllers thin.** The controller's job is to fetch the owner CRD, decide which components should exist, and +call `Reconcile` on each. Resource-level logic belongs in the component and its primitives. -**One component per user-visible feature.** If you want a `WebInterfaceReady` and a `DatabaseReady` condition on your CRD, those are two separate components. +**One component per user-visible feature.** If you want a `WebInterfaceReady` and a `DatabaseReady` condition on your +CRD, those are two separate components. -**Group by lifecycle.** Resources that must live and die together belong in the same component. If they have independent lifecycles, split them. +**Group by lifecycle.** Resources that must live and die together belong in the same component. If they have independent +lifecycles, split them. -**Use `ParticipationModeAuxiliary` for non-critical resources.** A metrics exporter sidecar should not block your primary component from becoming `Ready`. All resource types default to `ParticipationModeRequired` — set `ParticipationModeAuxiliary` explicitly when a resource's health should not gate the component condition. +**Use `ParticipationModeAuxiliary` for non-critical resources.** A metrics exporter sidecar should not block your +primary component from becoming `Ready`. All resource types default to `ParticipationModeRequired` — set +`ParticipationModeAuxiliary` explicitly when a resource's health should not gate the component condition. diff --git a/docs/primitives.md b/docs/primitives.md index 13fde6a6..dc2d4c2a 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -1,6 +1,8 @@ # Resource Primitives -The `primitives` package provides reusable, type-safe wrappers for individual Kubernetes objects. Primitives sit between the [Component layer](component.md) and raw Kubernetes resources — they handle the complexities of state synchronization, mutation, and lifecycle management so operator authors don't have to. +The `primitives` package provides reusable, type-safe wrappers for individual Kubernetes objects. Primitives sit between +the [Component layer](component.md) and raw Kubernetes resources — they handle the complexities of state +synchronization, mutation, and lifecycle management so operator authors don't have to. ## What a Primitive Is @@ -11,7 +13,8 @@ A primitive wraps a specific Kubernetes kind (e.g., `Deployment`, `ConfigMap`) a - **Mutation surfaces** — typed APIs for modifying the resource based on active features or version constraints. - **Field application rules** — precise control over which fields are merged or preserved during reconciliation. -Each primitive implements the `component.Resource` interface, and may additionally implement one or more [lifecycle interfaces](#lifecycle-interfaces) to participate in component status aggregation. +Each primitive implements the `component.Resource` interface, and may additionally implement one or more +[lifecycle interfaces](#lifecycle-interfaces) to participate in component status aggregation. ## Primitive Categories @@ -21,40 +24,55 @@ The framework categorizes primitives based on their runtime behavior. Examples: `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget` -These resources have a mostly static desired state. They are created or updated based on configuration but have no complex runtime convergence. They are considered `Ready` as long as they exist. They may optionally implement `Alive` or `Operational` for more granular tracking. +These resources have a mostly static desired state. They are created or updated based on configuration but have no +complex runtime convergence. They are considered `Ready` as long as they exist. They may optionally implement `Alive` or +`Operational` for more granular tracking. ### Workload Examples: `Deployment`, `StatefulSet`, `DaemonSet` -These resources represent long-running processes that require runtime convergence (pods being scheduled and becoming ready). They implement `Alive`, `Graceful`, and `Suspendable` — supporting health tracking, grace periods, and scaling to zero. +These resources represent long-running processes that require runtime convergence (pods being scheduled and becoming +ready). They implement `Alive`, `Graceful`, and `Suspendable` — supporting health tracking, grace periods, and scaling +to zero. ### Task Examples: `Job` -These resources represent short-lived operations that run to completion — database migrations, backups, initialization steps. They implement `Completable` and `Suspendable`. When suspended, tasks can be paused (if the underlying resource supports it) or deleted and recreated when resumed. +These resources represent short-lived operations that run to completion — database migrations, backups, initialization +steps. They implement `Completable` and `Suspendable`. When suspended, tasks can be paused (if the underlying resource +supports it) or deleted and recreated when resumed. ### Integration Examples: `Service`, `Ingress`, `Gateway`, `CronJob` -These resources define integration points with external or cluster-level systems (networking, load balancers, DNS, schedules). Their readiness depends on external controllers and may be delayed or partial. They implement `Operational` and/or `Suspendable`. +These resources define integration points with external or cluster-level systems (networking, load balancers, DNS, +schedules). Their readiness depends on external controllers and may be delayed or partial. They implement `Operational` +and/or `Suspendable`. ## Cluster-Scoped Primitives -Some Kubernetes resources are cluster-scoped — they have no namespace. Examples include `ClusterRole`, `ClusterRoleBinding`, and `PersistentVolume`. +Some Kubernetes resources are cluster-scoped — they have no namespace. Examples include `ClusterRole`, +`ClusterRoleBinding`, and `PersistentVolume`. -When implementing a primitive for a cluster-scoped kind, the primitive's builder must explicitly call `MarkClusterScoped()` on its internal `BaseBuilder` during construction. This changes `ValidateBase()` behavior: instead of requiring a non-empty namespace, it rejects a non-empty namespace. The primitive's builder is also responsible for providing an identity function that formats the identity string appropriately — typically omitting the namespace segment (e.g., `rbac.authorization.k8s.io/v1/ClusterRole/my-role` rather than including a namespace). +When implementing a primitive for a cluster-scoped kind, the primitive's builder must explicitly call +`MarkClusterScoped()` on its internal `BaseBuilder` during construction. This changes `ValidateBase()` behavior: instead +of requiring a non-empty namespace, it rejects a non-empty namespace. The primitive's builder is also responsible for +providing an identity function that formats the identity string appropriately — typically omitting the namespace segment +(e.g., `rbac.authorization.k8s.io/v1/ClusterRole/my-role` rather than including a namespace). -At reconcile time, the component framework automatically detects scope incompatibilities between the owner CRD and managed resources using the cluster's REST mapper. See [Cluster-Scoped Resources](component.md#cluster-scoped-resources) in the component documentation for details on owner reference behavior and garbage collection. +At reconcile time, the component framework automatically detects scope incompatibilities between the owner CRD and +managed resources using the cluster's REST mapper. See [Cluster-Scoped Resources](component.md#cluster-scoped-resources) +in the component documentation for details on owner reference behavior and garbage collection. ## Lifecycle Interfaces Primitives implement behavioral interfaces that the component layer uses for status aggregation: | Interface | Status values reported | Typical use | -|-------------------|----------------------------------------------------------|-------------------------------------------| +| ----------------- | -------------------------------------------------------- | ----------------------------------------- | | `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` | Deployments, StatefulSets, DaemonSets | | `Graceful` | `Healthy`, `Degraded`, `Down` | Workloads with slow or stalled rollouts | | `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | Any resource with a deactivation behavior | @@ -78,9 +96,12 @@ This ordering guarantees that mutations always operate on a predictable, fully-f ### Flavors -Flavors are reusable merge policies that run after baseline application but before mutations. Their purpose is to preserve fields that may be managed by external controllers or tools — sidecar injectors, autoscalers, annotation-based tooling — that the primitive should not overwrite. +Flavors are reusable merge policies that run after baseline application but before mutations. Their purpose is to +preserve fields that may be managed by external controllers or tools — sidecar injectors, autoscalers, annotation-based +tooling — that the primitive should not overwrite. Examples of what flavors can preserve: + - Labels and annotations added by external tools - Pod template metadata managed by injection webhooks - Fields managed by the Kubernetes HPA @@ -89,9 +110,11 @@ Flavors allow primitives to coexist in clusters where multiple controllers touch ## Mutation System -Primitives use a **plan-and-apply pattern**: instead of mutating the Kubernetes object directly, mutations record their intent through typed editors, which are applied in a single controlled pass. +Primitives use a **plan-and-apply pattern**: instead of mutating the Kubernetes object directly, mutations record their +intent through typed editors, which are applied in a single controlled pass. This design: + - **Prevents uncontrolled mutation** — changes are staged before any object is touched - **Enables composability** — independent features contribute edits without knowing about each other - **Guarantees ordering** — features apply in registration order; within a feature, categories apply in a fixed sequence @@ -102,14 +125,15 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: | Editor | Scope | -|------------------------|-------------------------------------------------------------------------| +| ---------------------- | ----------------------------------------------------------------------- | | `ContainerEditor` | Environment variables, arguments, resource limits, ports | | `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | | `DeploymentSpecEditor` | Replicas, update strategy, label selectors | | `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | | `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | -Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. +Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the +underlying Kubernetes struct while keeping the mutation scoped to that editor's target. ## Container Selectors @@ -122,14 +146,15 @@ selectors.ContainersNamed("web", "api") // multiple containers by name selectors.ContainerAtIndex(0) // container at a specific index ``` -Selectors are evaluated against the container list *after* any presence operations (add/remove) within the same mutation have been applied. This means a single mutation can safely add a container and then configure it. +Selectors are evaluated against the container list _after_ any presence operations (add/remove) within the same mutation +have been applied. This means a single mutation can safely add a container and then configure it. ## Built-in Primitives -| Primitive | Category | Documentation | -|--------------------------------------|------------|-----------------------------------------| -| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| Primitive | Category | Documentation | +| --------------------------- | -------- | ----------------------------------------- | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | ## Usage Examples @@ -202,9 +227,11 @@ type Resource interface { } ``` -Then implement whichever lifecycle interfaces your resource needs (`Alive`, `Suspendable`, etc.). See the [examples directory](../examples/) for complete implementations. +Then implement whichever lifecycle interfaces your resource needs (`Alive`, `Suspendable`, etc.). See the +[examples directory](../examples/) for complete implementations. Custom resources are appropriate when: + - You are managing a custom CRD with specialized health or readiness logic - The resource has unusual lifecycle semantics (e.g., must be deleted and recreated rather than updated in place) -- You need mutation behavior not covered by the standard editors \ No newline at end of file +- You need mutation behavior not covered by the standard editors diff --git a/docs/primitives/configmap.md b/docs/primitives/configmap.md index 840a6b42..7b6beaed 100644 --- a/docs/primitives/configmap.md +++ b/docs/primitives/configmap.md @@ -1,11 +1,13 @@ # ConfigMap Primitive -The `configmap` primitive is the framework's built-in static abstraction for managing Kubernetes `ConfigMap` resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.data` entries and object metadata. +The `configmap` primitive is the framework's built-in static abstraction for managing Kubernetes `ConfigMap` resources. +It integrates with the component lifecycle and provides a structured mutation API for managing `.data` entries and +object metadata. ## Capabilities | Capability | Detail | -|-----------------------|----------------------------------------------------------------------------------------------------------| +| --------------------- | -------------------------------------------------------------------------------------------------------- | | **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | | **Mutation pipeline** | Typed editors for `.data` entries and object metadata, with a raw escape hatch for free-form access | | **MergeYAML** | Deep-merges YAML patches into individual `.data` entries; composable across independent features | @@ -35,7 +37,10 @@ resource, err := configmap.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current ConfigMap with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.) and shared-controller fields (OwnerReferences, Finalizers) from the original live object. This ensures every reconciliation cycle produces a clean, predictable state without losing server-managed data. +`DefaultFieldApplicator` replaces the current ConfigMap with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.) and shared-controller fields (OwnerReferences, Finalizers) from the +original live object. This ensures every reconciliation cycle produces a clean, predictable state without losing +server-managed data. Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: @@ -51,9 +56,11 @@ resource, err := configmap.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `ConfigMap` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `ConfigMap` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature +with no version constraints and no `When()` conditions is also always enabled: ```go func MyFeatureMutation(version string) configmap.Mutation { @@ -68,7 +75,8 @@ func MyFeatureMutation(version string) configmap.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. ### Boolean-gated mutations @@ -109,14 +117,16 @@ All version constraints and `When()` conditions must be satisfied for a mutation ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are recorded: +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are +recorded: -| Step | Category | What it affects | -|------|-------------------|-----------------------------------------------| -| 1 | Metadata edits | Labels and annotations on the `ConfigMap` | -| 2 | Data edits | `.data` entries — Set, Remove, MergeYAML, Raw | +| Step | Category | What it affects | +| ---- | -------------- | --------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ConfigMap` | +| 2 | Data edits | `.data` entries — Set, Remove, MergeYAML, Raw | -Within each category, edits are applied in their registration order. Later features observe the ConfigMap as modified by all previous features. +Within each category, edits are applied in their registration order. Later features observe the ConfigMap as modified by +all previous features. ## Editors @@ -146,7 +156,8 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error { #### SetBinary and RemoveBinary -`SetBinary` sets a raw byte slice in `.binaryData`. `RemoveBinary` deletes a `.binaryData` key; it is a no-op if the key is absent. No helpers are provided beyond set and remove — format and encode the value before passing it in. +`SetBinary` sets a raw byte slice in `.binaryData`. `RemoveBinary` deletes a `.binaryData` key; it is a no-op if the key +is absent. No helpers are provided beyond set and remove — format and encode the value before passing it in. ```go m.EditData(func(e *editors.ConfigMapDataEditor) error { @@ -160,11 +171,14 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error { `MergeYAML` deep-merges a YAML patch string into the existing value at a key in `.data`. Merge semantics: -- If both the existing value and the patch are YAML mappings, their keys are merged recursively — keys present only in the base are preserved, keys present only in the patch are added, and keys present in both are resolved by applying `MergeYAML` recursively. +- If both the existing value and the patch are YAML mappings, their keys are merged recursively — keys present only in + the base are preserved, keys present only in the patch are added, and keys present in both are resolved by applying + `MergeYAML` recursively. - For all other types (scalars, sequences, mixed), the patch value wins. - If the key does not yet exist, the patch is written as-is. -This makes it suitable for composing contributions from independent features without each feature needing to know about the others: +This makes it suitable for composing contributions from independent features without each feature needing to know about +the others: ```go // Feature A contributes logging config. @@ -181,7 +195,8 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error { #### Raw Escape Hatches -`Raw()` returns the underlying `map[string]string` for `.data`. `RawBinary()` returns the underlying `map[string][]byte` for `.binaryData`. Both give direct access for free-form editing when none of the structured methods are sufficient: +`Raw()` returns the underlying `map[string]string` for `.data`. `RawBinary()` returns the underlying `map[string][]byte` +for `.binaryData`. Both give direct access for free-form editing when none of the structured methods are sufficient: ```go m.EditData(func(e *editors.ConfigMapDataEditor) error { @@ -213,17 +228,19 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { The `Mutator` exposes convenience wrappers for the most common `.data` operations: -| Method | Equivalent to | -|-----------------------------|-------------------------------------------------| -| `SetEntry(key, value)` | `EditData` → `e.Set(key, value)` | -| `RemoveEntry(key)` | `EditData` → `e.Remove(key)` | -| `MergeYAML(key, patch)` | `EditData` → `e.MergeYAML(key, patch)` | +| Method | Equivalent to | +| ----------------------- | -------------------------------------- | +| `SetEntry(key, value)` | `EditData` → `e.Set(key, value)` | +| `RemoveEntry(key)` | `EditData` → `e.Remove(key)` | +| `MergeYAML(key, patch)` | `EditData` → `e.MergeYAML(key, patch)` | -Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a single edit block. +Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a +single edit block. ## Flavors -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external +controllers or other tools. ### PreserveCurrentLabels @@ -237,7 +254,8 @@ resource, err := configmap.NewBuilder(base). ### PreserveCurrentAnnotations -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on +overlap. ```go resource, err := configmap.NewBuilder(base). @@ -247,7 +265,8 @@ resource, err := configmap.NewBuilder(base). ### PreserveExternalEntries -Preserves `.data` keys present on the live object but absent from the applied desired state. Applied values win on overlap. +Preserves `.data` keys present on the live object but absent from the applied desired state. Applied values win on +overlap. Use this when other controllers or admission webhooks inject entries into the ConfigMap that your operator does not own: @@ -261,7 +280,9 @@ Multiple flavors can be registered and run in registration order. ## Data Hash -Two utilities are provided for computing a stable SHA-256 hash of a ConfigMap's `.data` and `.binaryData` fields. A common use is to annotate a Deployment's pod template with this hash so that a configuration change triggers a rolling restart. +Two utilities are provided for computing a stable SHA-256 hash of a ConfigMap's `.data` and `.binaryData` fields. A +common use is to annotate a Deployment's pod template with this hash so that a configuration change triggers a rolling +restart. ### DataHash @@ -271,11 +292,13 @@ Two utilities are provided for computing a stable SHA-256 hash of a ConfigMap's hash, err := configmap.DataHash(cm) ``` -The hash is derived from the canonical JSON encoding of `.data` and `.binaryData` with map keys sorted alphabetically, so it is deterministic regardless of insertion order. Metadata fields (labels, annotations, etc.) are excluded. +The hash is derived from the canonical JSON encoding of `.data` and `.binaryData` with map keys sorted alphabetically, +so it is deterministic regardless of insertion order. Metadata fields (labels, annotations, etc.) are excluded. ### Resource.DesiredHash -`DesiredHash` computes the hash of what the operator *will write* — that is, the base object with all registered mutations applied — without performing a cluster read and without a second reconcile cycle: +`DesiredHash` computes the hash of what the operator _will write_ — that is, the base object with all registered +mutations applied — without performing a cluster read and without a second reconcile cycle: ```go cmResource, err := configmap.NewBuilder(base). @@ -286,13 +309,17 @@ cmResource, err := configmap.NewBuilder(base). hash, err := cmResource.DesiredHash() ``` -The hash covers only operator-controlled fields. Entries preserved by flavors from the live cluster (e.g. `PreserveExternalEntries`) are excluded — only changes to operator-owned content will change the hash. +The hash covers only operator-controlled fields. Entries preserved by flavors from the live cluster (e.g. +`PreserveExternalEntries`) are excluded — only changes to operator-owned content will change the hash. ### Annotating a Deployment pod template (single-pass pattern) -Build the configmap resource first, compute the hash, then pass it into the deployment resource factory. Both resources are registered with the same component, so the configmap is reconciled first and the deployment sees the correct hash on every cycle. +Build the configmap resource first, compute the hash, then pass it into the deployment resource factory. Both resources +are registered with the same component, so the configmap is reconciled first and the deployment sees the correct hash on +every cycle. -`DesiredHash` is defined on `*configmap.Resource`, not on the `component.Resource` interface, so keep the concrete type when you need to call it: +`DesiredHash` is defined on `*configmap.Resource`, not on the `component.Resource` interface, so keep the concrete type +when you need to call it: ```go cmResource, err := configmap.NewBuilder(base). @@ -336,7 +363,8 @@ func ChecksumAnnotationMutation(version, configHash string) deployment.Mutation } ``` -When the configmap mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. +When the configmap mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the +same reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. ## Full Example: Feature-Composed Configuration @@ -380,14 +408,21 @@ resource, err := configmap.NewBuilder(base). Build() ``` -When `MetricsEnabled` is true, the final `app.yaml` entry will contain the merged result of both patches. When false, only the base config is written. Neither mutation needs to know about the other. +When `MetricsEnabled` is true, the final `app.yaml` entry will contain the merged result of both patches. When false, +only the base config is written. Neither mutation needs to know about the other. ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. -**Use `MergeYAML` for composable config files.** When multiple features need to contribute to the same YAML entry, `MergeYAML` lets each feature contribute its section independently. Using `SetEntry` in multiple features for the same key means the last registration wins — only use that when replacement is the intended semantics. +**Use `MergeYAML` for composable config files.** When multiple features need to contribute to the same YAML entry, +`MergeYAML` lets each feature contribute its section independently. Using `SetEntry` in multiple features for the same +key means the last registration wins — only use that when replacement is the intended semantics. -**Use `PreserveExternalEntries` when sharing a ConfigMap.** If admission webhooks, external controllers, or manual operations add entries to a ConfigMap your operator manages, this flavor prevents your operator from silently deleting those entries each reconcile cycle. +**Use `PreserveExternalEntries` when sharing a ConfigMap.** If admission webhooks, external controllers, or manual +operations add entries to a ConfigMap your operator manages, this flavor prevents your operator from silently deleting +those entries each reconcile cycle. -**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. \ No newline at end of file +**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. diff --git a/docs/primitives/deployment.md b/docs/primitives/deployment.md index bc6ab65c..60b104b6 100644 --- a/docs/primitives/deployment.md +++ b/docs/primitives/deployment.md @@ -1,16 +1,18 @@ # Deployment Primitive -The `deployment` primitive is the framework's built-in workload abstraction for managing Kubernetes `Deployment` resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata. +The `deployment` primitive is the framework's built-in workload abstraction for managing Kubernetes `Deployment` +resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, +pod specs, and metadata. ## Capabilities -| Capability | Detail | -|-----------------------|-------------------------------------------------------------------------------------------------| -| **Health tracking** | Monitors `ReadyReplicas` and reports `Healthy`, `Creating`, `Updating`, `Scaling`, or `Failing` | -| **Graceful rollouts** | Detects stalled or failing rollouts via configurable grace periods | -| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, deployment spec, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, `Scaling`, or `Failing` | +| **Graceful rollouts** | Detects stalled or failing rollouts via configurable grace periods | +| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, deployment spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Building a Deployment Primitive @@ -35,15 +37,21 @@ resource, err := deployment.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current Deployment with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. +`DefaultFieldApplicator` replaces the current Deployment with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the +Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data +written by the API server or other controllers. -Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten (e.g., replicas managed by an HPA). +Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten (e.g., +replicas managed by an HPA). ## Mutations -Mutations are the primary mechanism for modifying a `Deployment` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `Deployment` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature +with no version constraints and no `When()` conditions is also always enabled: ```go func MyFeatureMutation(version string) deployment.Mutation { @@ -58,7 +66,8 @@ func MyFeatureMutation(version string) deployment.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. ### Boolean-gated mutations @@ -82,7 +91,8 @@ func TracingMutation(version string, enabled bool) deployment.Mutation { ### Version-gated mutations -Pass a `[]feature.VersionConstraint` to gate on a semver range. `VersionConstraint` is an interface — implement it using the `github.com/Masterminds/semver/v3` library or any other mechanism: +Pass a `[]feature.VersionConstraint` to gate on a semver range. `VersionConstraint` is an interface — implement it using +the `github.com/Masterminds/semver/v3` library or any other mechanism: ```go var legacyConstraint = mustSemverConstraint("< 2.0.0") @@ -109,20 +119,22 @@ All version constraints and `When()` conditions must be satisfied for a mutation ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded. This ensures structural consistency across mutations. +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded. This ensures structural consistency across mutations. -| Step | Category | What it affects | -|---|---|---| -| 1 | Deployment metadata edits | Labels and annotations on the `Deployment` object | -| 2 | DeploymentSpec edits | Replicas, progress deadline, revision history, etc. | -| 3 | Pod template metadata edits | Labels and annotations on the pod template | -| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | -| 5 | Regular container presence | Adding or removing containers from `spec.containers` | -| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | -| 7 | Init container presence | Adding or removing containers from `spec.initContainers` | -| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Deployment metadata edits | Labels and annotations on the `Deployment` object | +| 2 | DeploymentSpec edits | Replicas, progress deadline, revision history, etc. | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | -Container edits (steps 6 and 8) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation. +This means a single mutation can add a container and then configure it without selector resolution issues. ## Editors @@ -130,7 +142,8 @@ Container edits (steps 6 and 8) are evaluated against a snapshot taken *after* p Controls deployment-level settings via `m.EditDeploymentSpec`. -Available methods: `SetReplicas`, `SetPaused`, `SetMinReadySeconds`, `SetRevisionHistoryLimit`, `SetProgressDeadlineSeconds`, `Raw`. +Available methods: `SetReplicas`, `SetPaused`, `SetMinReadySeconds`, `SetRevisionHistoryLimit`, +`SetProgressDeadlineSeconds`, `Raw`. ```go m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error { @@ -155,7 +168,9 @@ m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error { Manages pod-level configuration via `m.EditPodSpec`. -Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, `SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { @@ -174,9 +189,11 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a [selector](../primitives.md#container-selectors). +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a +[selector](../primitives.md#container-selectors). -Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. ```go m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -201,7 +218,8 @@ m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEdito ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Deployment` object itself, or `m.EditPodTemplateMetadata` to target the pod template. +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Deployment` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. @@ -221,7 +239,9 @@ m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { ### Raw Escape Hatch -All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is insufficient. The mutation remains scoped to the editor's target — you cannot accidentally modify unrelated parts of the spec. +All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is +insufficient. The mutation remains scoped to the editor's target — you cannot accidentally modify unrelated parts of the +spec. ```go m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -237,7 +257,7 @@ m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEdito The `Mutator` also exposes convenience wrappers that target all containers at once: | Method | Equivalent to | -|-------------------------------|---------------------------------------------------------------| +| ----------------------------- | ------------------------------------------------------------- | | `EnsureReplicas(n)` | `EditDeploymentSpec` → `SetReplicas(n)` | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | | `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | @@ -284,14 +304,21 @@ func LoggingSidecarMutation(version string) deployment.Mutation { } ``` -Note: although `EditPodSpec` is called after `EnsureContainer` in the source, it is applied in step 4 (before container presence in step 5) per the internal ordering. Order your source calls for readability — the framework handles execution order. +Note: although `EditPodSpec` is called after `EnsureContainer` in the source, it is applied in step 4 (before container +presence in step 5) per the internal ordering. Order your source calls for readability — the framework handles execution +order. ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. -**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. The internal ordering within each mutation handles intra-mutation dependencies automatically. +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +The internal ordering within each mutation handles intra-mutation dependencies automatically. -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in the same mutation resolve correctly and reconciliation remains idempotent. +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly and reconciliation remains idempotent. -**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. \ No newline at end of file +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/examples/configmap-primitive/README.md b/examples/configmap-primitive/README.md index dac62ecd..21e5d50d 100644 --- a/examples/configmap-primitive/README.md +++ b/examples/configmap-primitive/README.md @@ -1,7 +1,7 @@ # ConfigMap Primitive Example -This example demonstrates the usage of the `configmap` primitive within the operator component framework. -It shows how to manage a Kubernetes ConfigMap as a component of a larger application, utilising features like: +This example demonstrates the usage of the `configmap` primitive within the operator component framework. It shows how +to manage a Kubernetes ConfigMap as a component of a larger application, utilising features like: - **Base Construction**: Initializing a ConfigMap with basic metadata. - **Feature Mutations**: Composing YAML configuration from independent, feature-gated mutations using `MergeYAML`. @@ -11,11 +11,13 @@ It shows how to manage a Kubernetes ConfigMap as a component of a larger applica ## Directory Structure -- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from `examples/shared/app`. +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from + `examples/shared/app`. - `features/`: Contains modular feature definitions: - - `mutations.go`: base config, version labelling, and feature-gated tracing and metrics sections. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. -- `resources/`: Contains the central `NewConfigMapResource` factory that assembles all features using `configmap.Builder`. + - `mutations.go`: base config, version labelling, and feature-gated tracing and metrics sections. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. +- `resources/`: Contains the central `NewConfigMapResource` factory that assembles all features using + `configmap.Builder`. - `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. ## Running the Example @@ -25,6 +27,7 @@ go run examples/configmap-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile through four spec variations, printing the composed `app.yaml` after each cycle. diff --git a/examples/custom-resource-implementation/README.md b/examples/custom-resource-implementation/README.md index 4566ace8..828b7692 100644 --- a/examples/custom-resource-implementation/README.md +++ b/examples/custom-resource-implementation/README.md @@ -11,12 +11,14 @@ go run examples/custom-resource-implementation/main.go ``` The example will simulate four different reconciliation scenarios: + 1. **Legacy Version (7.9.0)**: Demonstrates "Cleanup" and "Legacy Compatibility" features. 2. **Modern Version (8.1.0)**: Demonstrates "Tracing" and "Legacy Compatibility" features. 3. **Future Version (9.0.0)**: Demonstrates "Tracing" enabled and "Legacy Compatibility" disabled. 4. **Suspension**: Demonstrates how the framework handles scaling down/suspending a component. Example output: + ```bash $ go run examples/component-architecture-basics/main.go === Scenario 1: Reconciling Legacy Version 7.9.0 === @@ -60,41 +62,57 @@ Owner: example-latest (Version: 9.0.0, Suspended: true) ## Directory Structure - `resources/`: Custom resource wrappers for Kubernetes objects. - - `deployment_resource.go`: Implements the framework's `Resource`, `Alive`, `Suspendable`, and `DataExtractable` interfaces for `*appsv1.Deployment`. - - `deployment_mutator.go`: A "Planner-style" mutator for `*appsv1.Deployment` objects, allowing features to record intent without direct object manipulation. - - `deployment_builder.go`: Configurable builder for the `DeploymentResource`. - - `deployment_construction.go`: Factory for the core Deployment baseline configuration. + - `deployment_resource.go`: Implements the framework's `Resource`, `Alive`, `Suspendable`, and `DataExtractable` + interfaces for `*appsv1.Deployment`. + - `deployment_mutator.go`: A "Planner-style" mutator for `*appsv1.Deployment` objects, allowing features to record + intent without direct object manipulation. + - `deployment_builder.go`: Configurable builder for the `DeploymentResource`. + - `deployment_construction.go`: Factory for the core Deployment baseline configuration. - `features/`: Version-aware feature gates and mutations using the framework's `feature.Mutation` type. - - `constraints.go`: Example implementation of the `feature.VersionConstraint` interface using semver. - - `tracing_feature.go`: Example feature: adds tracing configuration (version-gated). - - `legacy_compat_feature.go`: Example feature: adds legacy compatibility flags (version-gated). - - `compatibility_cleanup_feature.go`: Example feature: removes/replaces deprecated settings for older versions. + - `constraints.go`: Example implementation of the `feature.VersionConstraint` interface using semver. + - `tracing_feature.go`: Example feature: adds tracing configuration (version-gated). + - `legacy_compat_feature.go`: Example feature: adds legacy compatibility flags (version-gated). + - `compatibility_cleanup_feature.go`: Example feature: removes/replaces deprecated settings for older versions. - `exampleapp/`: A mock application demonstrating the framework usage. - - `owner.go`: A mock Custom Resource that implements the real `component.OperatorCRD` interface. - - `controller_example.go`: A mock controller showing how to use the `component.Builder` and `ReconcileContext`. + - `owner.go`: A mock Custom Resource that implements the real `component.OperatorCRD` interface. + - `controller_example.go`: A mock controller showing how to use the `component.Builder` and `ReconcileContext`. - `main.go`: Entry point that demonstrates the assembly and usage of the components. ## Key Concepts ### Component Framework + The framework (`pkg/component`) groups related resources into a single logical unit. It centralizes: + - **Reconciliation Flow**: Standardized apply/delete/status logic. - **Status Aggregation**: Computing a single "Ready" condition from multiple resources. - **Lifecycle Management**: Handling suspension and "alive" checks consistently. ### Feature Mutations + Instead of using complex `if/else` logic inside resource configuration, we use **Feature Mutations**: + - **Baseline**: A core, stable configuration for the resource (applied in `Mutate(current client.Object)`). - **Mutations**: Small, focused functions that modify the resource if a feature is enabled. -- **Planner-style Mutator**: A restricted set of methods (`DeploymentResourceMutator`) that mutations use to record their *intent*. This avoids direct manipulation of the Kubernetes object by features and allows the mutator to resolve changes (like slice updates) efficiently in a single final pass. -- **Registration**: Mutations are registered through a builder and applied during the resource's `Mutate(current client.Object)` call. +- **Planner-style Mutator**: A restricted set of methods (`DeploymentResourceMutator`) that mutations use to record + their _intent_. This avoids direct manipulation of the Kubernetes object by features and allows the mutator to resolve + changes (like slice updates) efficiently in a single final pass. +- **Registration**: Mutations are registered through a builder and applied during the resource's + `Mutate(current client.Object)` call. - **Application Process**: The `Mutate` implementation follows a precise sequence: - 1. **Core Baseline**: Apply all desired fields from the core resource baseline to the `current` server object. - 2. **Feature Mutations**: Apply all registered feature mutations to the `current` object, allowing them to override or extend the baseline. - 3. **State Sync**: Update the internal `desired` state with the fully mutated `current` object. This ensures that subsequent calls to `ConvergingStatus` and `ExtractData` operate on the final, augmented state of the resource. + 1. **Core Baseline**: Apply all desired fields from the core resource baseline to the `current` server object. + 2. **Feature Mutations**: Apply all registered feature mutations to the `current` object, allowing them to override + or extend the baseline. + 3. **State Sync**: Update the internal `desired` state with the fully mutated `current` object. This ensures that + subsequent calls to `ConvergingStatus` and `ExtractData` operate on the final, augmented state of the resource. ### Resource Interface + The `Resource` interface in `pkg/component` is the central contract for all objects managed by the framework: + - `Identity() string`: Returns a unique identifier for the resource (e.g., `apps/v1/Deployment/web-ui`). -- `Object() (client.Object, error)`: Returns a fresh copy of the baseline resource object before reconciliation. Returns the applied k8s object after reconciling. -- `Mutate(current client.Object) error`: Applies the core baseline, feature mutation intents, and any deferred mutations (like suspension) to the provided `current` server object. It also synchronizes the internal state to reflect these changes. +- `Object() (client.Object, error)`: Returns a fresh copy of the baseline resource object before reconciliation. Returns + the applied k8s object after reconciling. +- `Mutate(current client.Object) error`: Applies the core baseline, feature mutation intents, and any deferred mutations + (like suspension) to the provided `current` server object. It also synchronizes the internal state to reflect these + changes. diff --git a/examples/deployment-primitive/README.md b/examples/deployment-primitive/README.md index 949acc07..e4f4402f 100644 --- a/examples/deployment-primitive/README.md +++ b/examples/deployment-primitive/README.md @@ -1,12 +1,15 @@ # Deployment Primitive Example -This example demonstrates the usage of the `deployment` primitive within the operator component framework. -It shows how to manage a Kubernetes Deployment as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `deployment` primitive within the operator component framework. It shows how +to manage a Kubernetes Deployment as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a Deployment with basic metadata and spec. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). -- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health assessment during rollouts (`GraceStatus`). +- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the + `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual + edits). +- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health + assessment during rollouts (`GraceStatus`). - **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. - **Data Extraction**: Harvesting information from the reconciled resource. @@ -14,10 +17,11 @@ It shows how to manage a Kubernetes Deployment as a component of a larger applic - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. - - `status.go`: implementation of custom handlers for convergence, grace, and suspension. -- `resources/`: Contains the central `NewDeploymentResource` factory that assembles all features using the `deployment.Builder`. + - `mutations.go`: sidecar injection, env vars, and version-based image updates. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. + - `status.go`: implementation of custom handlers for convergence, grace, and suspension. +- `resources/`: Contains the central `NewDeploymentResource` factory that assembles all features using the + `deployment.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. ## Running the Example @@ -29,6 +33,7 @@ go run examples/deployment-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile the `ExampleApp` components. diff --git a/pkg/component/concepts/converging.go b/pkg/component/concepts/converging.go index b7cc9e78..420d3070 100644 --- a/pkg/component/concepts/converging.go +++ b/pkg/component/concepts/converging.go @@ -1,6 +1,10 @@ package concepts -import "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +import ( + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) // ConvergingOperation represents the result of a CreateOrUpdate operation on a resource. // It provides context to the Alive interface to help determine the ConvergingStatus. @@ -25,3 +29,36 @@ func ConvergingOperationFromOperationResult(result controllerutil.OperationResul } return ConvergingOperationNone } + +// StaleGenerationStatus checks whether a resource's controller has observed the latest spec by +// comparing ObservedGeneration against the object's Generation. If the controller is behind, +// it returns a non-nil AliveStatusWithReason with an appropriate Creating or Updating status. +// If the generation is current, it returns nil. +// +// This should be called at the top of a DefaultConvergingStatusHandler before evaluating +// readiness fields, which may be stale when the controller has not yet reconciled the +// latest generation. +// +// if status := concepts.StaleGenerationStatus(op, obj.Status.ObservedGeneration, obj.Generation, "deployment"); status != nil { +// return *status, nil +// } +func StaleGenerationStatus( + op ConvergingOperation, observedGeneration, generation int64, resourceKind string, +) *AliveStatusWithReason { + if observedGeneration >= generation { + return nil + } + + var status AliveConvergingStatus + switch op { + case ConvergingOperationCreated: + status = AliveConvergingStatusCreating + default: + status = AliveConvergingStatusUpdating + } + + return &AliveStatusWithReason{ + Status: status, + Reason: fmt.Sprintf("Waiting for %s controller to observe latest spec", resourceKind), + } +} diff --git a/pkg/component/concepts/converging_test.go b/pkg/component/concepts/converging_test.go index 669dfa0a..302db724 100644 --- a/pkg/component/concepts/converging_test.go +++ b/pkg/component/concepts/converging_test.go @@ -26,3 +26,69 @@ func TestConvergingOperationFromOperationResult(t *testing.T) { }) } } + +func TestStaleGenerationStatus(t *testing.T) { + tests := []struct { + name string + op ConvergingOperation + observedGeneration int64 + generation int64 + wantNil bool + wantStatus AliveConvergingStatus + }{ + { + name: "current generation returns nil", + op: ConvergingOperationUpdated, + observedGeneration: 2, + generation: 2, + wantNil: true, + }, + { + name: "ahead generation returns nil", + op: ConvergingOperationUpdated, + observedGeneration: 3, + generation: 2, + wantNil: true, + }, + { + name: "zero values return nil", + op: ConvergingOperationNone, + observedGeneration: 0, + generation: 0, + wantNil: true, + }, + { + name: "stale after create returns creating", + op: ConvergingOperationCreated, + observedGeneration: 0, + generation: 1, + wantStatus: AliveConvergingStatusCreating, + }, + { + name: "stale after update returns updating", + op: ConvergingOperationUpdated, + observedGeneration: 1, + generation: 2, + wantStatus: AliveConvergingStatusUpdating, + }, + { + name: "stale with no operation returns updating", + op: ConvergingOperationNone, + observedGeneration: 1, + generation: 2, + wantStatus: AliveConvergingStatusUpdating, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := StaleGenerationStatus(tt.op, tt.observedGeneration, tt.generation, "test") + if tt.wantNil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + assert.Equal(t, tt.wantStatus, result.Status) + } + }) + } +} diff --git a/pkg/primitives/deployment/handlers.go b/pkg/primitives/deployment/handlers.go index 095945fc..0260085c 100644 --- a/pkg/primitives/deployment/handlers.go +++ b/pkg/primitives/deployment/handlers.go @@ -9,13 +9,23 @@ import ( // DefaultConvergingStatusHandler is the default logic for determining if a Deployment has reached its desired state. // -// It considers a Deployment ready when its Status.ReadyReplicas matches the Spec.Replicas (defaulting to 1 if nil). +// It considers a Deployment ready when the deployment controller has observed the current generation +// (Status.ObservedGeneration >= ObjectMeta.Generation) and Status.ReadyReplicas matches the +// Spec.Replicas (defaulting to 1 if nil). If the controller has not yet observed the latest spec, +// the handler reports Creating (when the resource was just created) or Updating (otherwise) to avoid +// falsely reporting health based on stale status fields. // // This function is used as the default handler by the Resource if no custom handler is registered via // Builder.WithCustomConvergeStatus. It can be reused within custom handlers to augment the default behavior. func DefaultConvergingStatusHandler( op concepts.ConvergingOperation, deployment *appsv1.Deployment, ) (concepts.AliveStatusWithReason, error) { + if status := concepts.StaleGenerationStatus( + op, deployment.Status.ObservedGeneration, deployment.Generation, "deployment", + ); status != nil { + return *status, nil + } + desiredReplicas := int32(1) if deployment.Spec.Replicas != nil { desiredReplicas = *deployment.Spec.Replicas diff --git a/pkg/primitives/deployment/handlers_test.go b/pkg/primitives/deployment/handlers_test.go index 5fcb15ac..aaa1d7f6 100644 --- a/pkg/primitives/deployment/handlers_test.go +++ b/pkg/primitives/deployment/handlers_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -34,11 +35,13 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { name: "ready with custom replicas", op: concepts.ConvergingOperationUpdated, deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, Spec: appsv1.DeploymentSpec{ Replicas: ptr.To(int32(3)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 3, + ObservedGeneration: 2, + ReadyReplicas: 3, }, }, wantStatus: concepts.AliveConvergingStatusHealthy, @@ -86,6 +89,54 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { wantStatus: concepts.AliveConvergingStatusScaling, wantReason: "Waiting for replicas: 1/3 ready", }, + { + name: "stale observed generation after create", + op: concepts.ConvergingOperationCreated, + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for deployment controller to observe latest spec", + }, + { + name: "stale observed generation after update", + op: concepts.ConvergingOperationUpdated, + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: 2, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for deployment controller to observe latest spec", + }, + { + name: "stale observed generation with no operation", + op: concepts.ConvergingOperationNone, + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for deployment controller to observe latest spec", + }, } for _, tt := range tests { From 7e823690ac56952ec5aec4266f4dd50ee08f56f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:02 +0000 Subject: [PATCH 22/32] style: format markdown files with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 103 ++++++++++++++++----------- examples/cronjob-primitive/README.md | 13 ++-- 2 files changed, 69 insertions(+), 47 deletions(-) diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index 5c550d29..996bb5fd 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -1,19 +1,24 @@ # CronJob Primitive -The `cronjob` primitive is the framework's built-in integration abstraction for managing Kubernetes `CronJob` resources. It integrates with the component lifecycle through the Operational and Suspendable concepts, and provides a rich mutation API for managing the CronJob schedule, job template, pod spec, and containers. +The `cronjob` primitive is the framework's built-in integration abstraction for managing Kubernetes `CronJob` resources. +It integrates with the component lifecycle through the Operational and Suspendable concepts, and provides a rich +mutation API for managing the CronJob schedule, job template, pod spec, and containers. ## Capabilities -| Capability | Detail | -|--------------------------|----------------------------------------------------------------------------------------------| -| **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) | -| **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +| Capability | Detail | +| ------------------------ | ------------------------------------------------------------------------------------------- | +| **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) | +| **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Default Field Application -`DefaultFieldApplicator` replaces the current CronJob with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. +`DefaultFieldApplicator` replaces the current CronJob with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the +Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data +written by the API server or other controllers. Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. @@ -52,7 +57,8 @@ resource, err := cronjob.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `CronJob` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `CronJob` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. ```go func MyScheduleMutation(version string) cronjob.Mutation { @@ -93,21 +99,23 @@ func TimeZoneMutation(version string, enabled bool) cronjob.Mutation { ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded. +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded. -| Step | Category | What it affects | -|---|---|---| -| 1 | CronJob metadata edits | Labels and annotations on the `CronJob` object | -| 2 | CronJobSpec edits | Schedule, concurrency policy, time zone, history limits | -| 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL | -| 4 | Pod template metadata edits | Labels and annotations on the pod template | -| 5 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | -| 6 | Regular container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.containers` | -| 7 | Regular container edits | Env vars, args, resources (snapshot taken after step 6) | -| 8 | Init container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.initContainers` | -| 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) | +| Step | Category | What it affects | +| ---- | --------------------------- | --------------------------------------------------------------------------------------- | +| 1 | CronJob metadata edits | Labels and annotations on the `CronJob` object | +| 2 | CronJobSpec edits | Schedule, concurrency policy, time zone, history limits | +| 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL | +| 4 | Pod template metadata edits | Labels and annotations on the pod template | +| 5 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 6 | Regular container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.containers` | +| 7 | Regular container edits | Env vars, args, resources (snapshot taken after step 6) | +| 8 | Init container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.initContainers` | +| 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) | -Container edits (steps 7 and 9) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. +Container edits (steps 7 and 9) are evaluated against a snapshot taken _after_ presence operations in the same mutation. +This means a single mutation can add a container and then configure it without selector resolution issues. ## Editors @@ -115,7 +123,8 @@ Container edits (steps 7 and 9) are evaluated against a snapshot taken *after* p Controls CronJob-level settings via `m.EditCronJobSpec`. -Available methods: `SetSchedule`, `SetConcurrencyPolicy`, `SetStartingDeadlineSeconds`, `SetSuccessfulJobsHistoryLimit`, `SetFailedJobsHistoryLimit`, `SetTimeZone`, `Raw`. +Available methods: `SetSchedule`, `SetConcurrencyPolicy`, `SetStartingDeadlineSeconds`, `SetSuccessfulJobsHistoryLimit`, +`SetFailedJobsHistoryLimit`, `SetTimeZone`, `Raw`. ```go m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { @@ -132,7 +141,8 @@ Note: `spec.suspend` is not exposed through the editor — it is managed by the Controls the embedded job template spec via `m.EditJobSpec`. -Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, `SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. +Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, +`SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. ```go m.EditJobSpec(func(e *editors.JobSpecEditor) error { @@ -146,7 +156,9 @@ m.EditJobSpec(func(e *editors.JobSpecEditor) error { Manages pod-level configuration via `m.EditPodSpec`. -Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, `SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { @@ -158,9 +170,11 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a [selector](../primitives.md#container-selectors). +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a +[selector](../primitives.md#container-selectors). -Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. ```go m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerEditor) error { @@ -172,7 +186,8 @@ m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerE ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `CronJob` object itself, or `m.EditPodTemplateMetadata` to target the pod template. +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `CronJob` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. @@ -188,7 +203,7 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { The `Mutator` also exposes convenience wrappers that target all containers at once: | Method | Equivalent to | -|-------------------------------|---------------------------------------------------------------| +| ----------------------------- | ------------------------------------------------------------- | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | | `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | | `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | @@ -198,24 +213,26 @@ The `Mutator` also exposes convenience wrappers that target all containers at on The CronJob primitive reports operational status based on the CronJob's scheduling history: -| Status | Condition | -|---------------|------------------------------------| -| `OperationPending` | `Status.LastScheduleTime == nil` | -| `Operational` | `Status.LastScheduleTime != nil` | +| Status | Condition | +| ------------------ | -------------------------------- | +| `OperationPending` | `Status.LastScheduleTime == nil` | +| `Operational` | `Status.LastScheduleTime != nil` | Failures are reported on the spawned Job resources, not on the CronJob itself. ## Suspension -When the component is suspended, the CronJob primitive sets `spec.suspend = true`. This prevents the CronJob controller from creating new Job objects. Existing active jobs continue to run. +When the component is suspended, the CronJob primitive sets `spec.suspend = true`. This prevents the CronJob controller +from creating new Job objects. Existing active jobs continue to run. -| Status | Condition | -|--------------|----------------------------------------------------------| -| `Suspended` | `spec.suspend == true` and no active jobs | -| `Suspending` | `spec.suspend == true` but active jobs still running | -| `Suspending` | Waiting for suspend flag to be applied | +| Status | Condition | +| ------------ | ---------------------------------------------------- | +| `Suspended` | `spec.suspend == true` and no active jobs | +| `Suspending` | `spec.suspend == true` but active jobs still running | +| `Suspending` | Waiting for suspend flag to be applied | -On unsuspend, the `DefaultFieldApplicator` restores the desired state (without `spec.suspend = true`), allowing the CronJob to resume scheduling. +On unsuspend, the `DefaultFieldApplicator` restores the desired state (without `spec.suspend = true`), allowing the +CronJob to resume scheduling. The CronJob is never deleted on suspend (`DeleteOnSuspend = false`). @@ -225,6 +242,8 @@ The CronJob is never deleted on suspend (`DeleteOnSuspend = false`). **Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in the same mutation resolve correctly. +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly. -**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/examples/cronjob-primitive/README.md b/examples/cronjob-primitive/README.md index 41bb98e1..d20ab809 100644 --- a/examples/cronjob-primitive/README.md +++ b/examples/cronjob-primitive/README.md @@ -1,10 +1,11 @@ # CronJob Primitive Example -This example demonstrates the usage of the `cronjob` primitive within the operator component framework. -It shows how to manage a Kubernetes CronJob as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `cronjob` primitive within the operator component framework. It shows how to +manage a Kubernetes CronJob as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a CronJob with a schedule, job template, and containers. -- **Feature Mutations**: Applying conditional changes (tracing env vars, metrics annotations, version-based image updates) using the `Mutator`. +- **Feature Mutations**: Applying conditional changes (tracing env vars, metrics annotations, version-based image + updates) using the `Mutator`. - **Field Flavors**: Preserving labels and annotations that might be managed by external tools. - **Suspension**: Suspending the CronJob by setting `spec.suspend = true`. - **Data Extraction**: Harvesting information from the reconciled resource. @@ -13,8 +14,9 @@ It shows how to manage a Kubernetes CronJob as a component of a larger applicati - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: tracing env vars, metrics annotations, and version-based image updates. -- `resources/`: Contains the central `NewCronJobResource` factory that assembles all features using the `cronjob.Builder`. + - `mutations.go`: tracing env vars, metrics annotations, and version-based image updates. +- `resources/`: Contains the central `NewCronJobResource` factory that assembles all features using the + `cronjob.Builder`. - `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. ## Running the Example @@ -26,6 +28,7 @@ go run examples/cronjob-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile the `ExampleApp` components through multiple spec changes. From 52aa7a211d3d235d6755e10eaf8d23dd2c18453d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:39:44 +0000 Subject: [PATCH 23/32] fix: address PR review comments for cronjob primitive - Fix BeginFeature N+1 plan issue: first mutation reuses initial plan from NewMutator, BeginFeature only called between subsequent mutations - Remove duplicate fmt-md from Makefile all target (already run via fmt) - Align .tool-versions golang to 1.25.6 to match go.mod - Add CronJob to Built-in Primitives table in docs/primitives.md - Add cronjob-primitive and configmap-primitive to README examples table Co-Authored-By: Claude Opus 4.6 (1M context) --- .tool-versions | 2 +- Makefile | 2 +- README.md | 2 + docs/primitives.md | 9 +-- internal/generic/mutate_helper.go | 4 +- internal/generic/mutate_helper_test.go | 82 +++++++++++++++++++++++++- 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/.tool-versions b/.tool-versions index 6edfef3b..43e7b917 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -golang 1.26.1 +golang 1.25.6 golangci-lint 2.11.2 nodejs 25.1.0 diff --git a/Makefile b/Makefile index 29424227..729fe39f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all -all: fmt fmt-md lint test build-examples +all: fmt lint test build-examples ##@ General diff --git a/README.md b/README.md index d26b5be4..432e50a1 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,8 @@ The [examples directory](examples/) contains runnable, end-to-end implementation | Example | Description | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`deployment-primitive`](examples/deployment-primitive/) | Core Deployment primitive: mutations, flavors, suspension, data extraction | +| [`cronjob-primitive`](examples/cronjob-primitive/) | CronJob primitive: schedule mutations, job template configuration, suspension | +| [`configmap-primitive`](examples/configmap-primitive/) | ConfigMap primitive: data mutations, field application, immutability | | [`custom-resource-implementation`](examples/custom-resource-implementation/) | Full custom resource wrapper implementing lifecycle interfaces and version-gated mutations | Run any example with: diff --git a/docs/primitives.md b/docs/primitives.md index dc2d4c2a..8a022148 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -151,10 +151,11 @@ have been applied. This means a single mutation can safely add a container and t ## Built-in Primitives -| Primitive | Category | Documentation | -| --------------------------- | -------- | ----------------------------------------- | -| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| Primitive | Category | Documentation | +| --------------------------- | ----------- | ----------------------------------------- | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | ## Usage Examples diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index baede18f..bbd05826 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -42,8 +42,8 @@ func ApplyMutations[T client.Object, M MutatorApplier]( mutator := newMutator(applied) fm, isFeatureMutator := any(mutator).(FeatureMutator) - for _, mutation := range mutations { - if isFeatureMutator { + for i, mutation := range mutations { + if isFeatureMutator && i > 0 { fm.BeginFeature() } diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index af38c3ba..9b3080e3 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -90,7 +90,6 @@ func TestApplyMutationsOrder(t *testing.T) { "defaultApplicator", "flavor1", "newMutator", - "mutator.BeginFeature", "mutation1", "mutator.Apply", "mutator.BeginFeature", @@ -104,3 +103,84 @@ func TestApplyMutationsOrder(t *testing.T) { assert.Equal(t, event, recorder.events[i], "at index %d", i) } } + +func TestApplyMutationsOrder_MultipleMutations(t *testing.T) { + recorder := &orderRecorder{} + + current := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + desired := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + defaultApplicator := func(_, _ *corev1.ConfigMap) error { + recorder.record("defaultApplicator") + return nil + } + + newMutator := func(_ *corev1.ConfigMap) *recordingMutator { + recorder.record("newMutator") + return &recordingMutator{recorder: recorder} + } + + mutations := []Mutation[*recordingMutator]{ + { + Name: "feat1", + Feature: alwaysEnabled{}, + Mutate: func(_ *recordingMutator) error { + recorder.record("mutation1") + return nil + }, + }, + { + Name: "feat2", + Feature: alwaysEnabled{}, + Mutate: func(_ *recordingMutator) error { + recorder.record("mutation2") + return nil + }, + }, + { + Name: "feat3", + Feature: alwaysEnabled{}, + Mutate: func(_ *recordingMutator) error { + recorder.record("mutation3") + return nil + }, + }, + } + + _, err := ApplyMutations[*corev1.ConfigMap, *recordingMutator]( + current, + desired, + defaultApplicator, + nil, + nil, + newMutator, + mutations, + nil, + ) + + require.NoError(t, err) + + // First mutation uses the initial plan from NewMutator. + // Subsequent mutations get a new plan via BeginFeature. + // This yields exactly N plans for N mutations (no unused empty plan). + expectedOrder := []string{ + "defaultApplicator", + "newMutator", + "mutation1", + "mutator.BeginFeature", + "mutation2", + "mutator.BeginFeature", + "mutation3", + "mutator.Apply", + } + + require.Len(t, recorder.events, len(expectedOrder), "events: %v", recorder.events) + + for i, event := range expectedOrder { + assert.Equal(t, event, recorder.events[i], "at index %d", i) + } +} From 498d264ed6ed11abe6cb8b8e1f5631880b207154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 02:12:40 +0000 Subject: [PATCH 24/32] docs: clarify spec.suspend note in cronjob documentation Rephrase the note about spec.suspend to acknowledge that Raw() can be used to set it, while still recommending the framework's suspend mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index 996bb5fd..aa6a2626 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -135,7 +135,7 @@ m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { }) ``` -Note: `spec.suspend` is not exposed through the editor — it is managed by the framework's suspension system. +Note: no typed helper is provided for `spec.suspend`; it can be set via `Raw()` if needed, but suspension should typically be handled via the framework's suspend mechanism. ### JobSpecEditor From 7b7bbd292cd45ae6fd5c98455e7ba72044252534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:14:59 +0000 Subject: [PATCH 25/32] style: format cronjob docs with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index aa6a2626..f83db4e4 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -135,7 +135,8 @@ m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { }) ``` -Note: no typed helper is provided for `spec.suspend`; it can be set via `Raw()` if needed, but suspension should typically be handled via the framework's suspend mechanism. +Note: no typed helper is provided for `spec.suspend`; it can be set via `Raw()` if needed, but suspension should +typically be handled via the framework's suspend mechanism. ### JobSpecEditor From d6afd3f5e4d5c2ec1b25818ee61ee2f764b65df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:23:16 +0000 Subject: [PATCH 26/32] fix: check Build() errors in flavors tests and document BeginFeature semantics - Add require.NoError checks for Builder.Build() in all three TestMutate_OrderingAndFlavors subtests to fail clearly on builder validation errors instead of risking nil dereferences. - Add comment in ApplyMutations explaining why BeginFeature is not called before the first mutation (newMutator creates the initial scope). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/generic/mutate_helper.go | 2 ++ pkg/primitives/cronjob/flavors_test.go | 15 +++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index bbd05826..f44d0d54 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -42,6 +42,8 @@ func ApplyMutations[T client.Object, M MutatorApplier]( mutator := newMutator(applied) fm, isFeatureMutator := any(mutator).(FeatureMutator) + // BeginFeature is called before the second and subsequent mutations to create a new + // planning scope. The first mutation uses the initial scope created by newMutator. for i, mutation := range mutations { if isFeatureMutator && i > 0 { fm.BeginFeature() diff --git a/pkg/primitives/cronjob/flavors_test.go b/pkg/primitives/cronjob/flavors_test.go index c1388eaa..f08a2b85 100644 --- a/pkg/primitives/cronjob/flavors_test.go +++ b/pkg/primitives/cronjob/flavors_test.go @@ -32,11 +32,12 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(PreserveCurrentLabels). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, "desired", current.Labels["app"]) @@ -61,12 +62,13 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return nil } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor1). WithFieldApplicationFlavor(flavor2). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, []string{"flavor1", "flavor2"}, order) }) @@ -78,11 +80,12 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return flavorErr } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply field application flavor") assert.True(t, errors.Is(err, flavorErr)) From 7ecd3a9513e42e3d6763b27acc3a1e296dbb5385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:58:25 +0000 Subject: [PATCH 27/32] fix: do not initialize empty plan on cronjob mutator construction Apply the same pattern from deployment/configmap: NewMutator no longer creates an initial feature plan. BeginFeature must be called before registering mutations. Also fix mutate_helper to call BeginFeature before every mutation (including the first). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/generic/mutate_helper.go | 7 +- internal/generic/mutate_helper_test.go | 6 +- pkg/primitives/cronjob/handlers_test.go | 1 + pkg/primitives/cronjob/mutator.go | 6 +- pkg/primitives/cronjob/mutator_test.go | 88 ++++++++++++++++++++++++- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index f44d0d54..1d5d151f 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -42,10 +42,9 @@ func ApplyMutations[T client.Object, M MutatorApplier]( mutator := newMutator(applied) fm, isFeatureMutator := any(mutator).(FeatureMutator) - // BeginFeature is called before the second and subsequent mutations to create a new - // planning scope. The first mutation uses the initial scope created by newMutator. - for i, mutation := range mutations { - if isFeatureMutator && i > 0 { + // BeginFeature is called before each mutation to create a new planning scope. + for _, mutation := range mutations { + if isFeatureMutator { fm.BeginFeature() } diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index 9b3080e3..986496b1 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -90,6 +90,7 @@ func TestApplyMutationsOrder(t *testing.T) { "defaultApplicator", "flavor1", "newMutator", + "mutator.BeginFeature", "mutation1", "mutator.Apply", "mutator.BeginFeature", @@ -164,12 +165,11 @@ func TestApplyMutationsOrder_MultipleMutations(t *testing.T) { require.NoError(t, err) - // First mutation uses the initial plan from NewMutator. - // Subsequent mutations get a new plan via BeginFeature. - // This yields exactly N plans for N mutations (no unused empty plan). + // Each mutation gets its own plan via BeginFeature. expectedOrder := []string{ "defaultApplicator", "newMutator", + "mutator.BeginFeature", "mutation1", "mutator.BeginFeature", "mutation2", diff --git a/pkg/primitives/cronjob/handlers_test.go b/pkg/primitives/cronjob/handlers_test.go index 270a7f9f..f911d3f5 100644 --- a/pkg/primitives/cronjob/handlers_test.go +++ b/pkg/primitives/cronjob/handlers_test.go @@ -71,6 +71,7 @@ func TestDefaultSuspendMutationHandler(t *testing.T) { }, } mutator := NewMutator(cj) + mutator.BeginFeature() err := DefaultSuspendMutationHandler(mutator) require.NoError(t, err) err = mutator.Apply() diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index ac222cd5..38feb604 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -49,13 +49,11 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given CronJob. +// BeginFeature must be called before registering any mutations. func NewMutator(current *batchv1.CronJob) *Mutator { - m := &Mutator{ + return &Mutator{ current: current, - plans: []featurePlan{{}}, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go index db78fb2d..982e5418 100644 --- a/pkg/primitives/cronjob/mutator_test.go +++ b/pkg/primitives/cronjob/mutator_test.go @@ -18,11 +18,81 @@ func TestNewMutator(t *testing.T) { m := NewMutator(cj) assert.NotNil(t, m) assert.Equal(t, cj, m.current) + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + }, + }, + } + m := NewMutator(cj) + + // Record mutations in the first feature plan + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/5 * * * *") + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1" + return nil + }) + + // Start a new feature + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 * * * *") + return nil + }) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].cronjobSpecEdits, 1, "first plan should have one spec edit") + assert.Len(t, m.plans[0].containerEdits, 1, "first plan should have one container edit") + assert.Len(t, m.plans[1].cronjobSpecEdits, 1, "second plan should have one spec edit") + assert.Empty(t, m.plans[1].containerEdits, "second plan should have no container edits") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/5 * * * *") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "single feature should have exactly one plan") } func TestMutator_EditObjectMetadata(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Labels = map[string]string{"cronjob": "label"} return nil @@ -36,6 +106,7 @@ func TestMutator_EditObjectMetadata(t *testing.T) { func TestMutator_EditCronJobSpec(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { e.SetSchedule("*/5 * * * *") e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) @@ -51,6 +122,7 @@ func TestMutator_EditCronJobSpec(t *testing.T) { func TestMutator_EditJobSpec(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditJobSpec(func(e *editors.JobSpecEditor) error { e.SetBackoffLimit(3) e.SetCompletions(1) @@ -66,6 +138,7 @@ func TestMutator_EditJobSpec(t *testing.T) { func TestMutator_EditPodTemplateMetadata(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Annotations = map[string]string{"pod": "ann"} return nil @@ -79,6 +152,7 @@ func TestMutator_EditPodTemplateMetadata(t *testing.T) { func TestMutator_EditPodSpec(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.Raw().ServiceAccountName = "my-sa" return nil @@ -108,6 +182,7 @@ func TestMutator_EditContainers(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "c1-image" return nil @@ -151,6 +226,7 @@ func TestMutator_EnvVars(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) @@ -198,6 +274,7 @@ func TestMutator_Args(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EnsureContainerArg("--change=new") m.EnsureContainerArg("--add") m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) @@ -232,6 +309,7 @@ func TestMutator_ContainerPresence(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) m.RemoveContainer("sidecar") m.EnsureContainer(corev1.Container{Name: "new-container", Image: "new-image"}) @@ -265,6 +343,7 @@ func TestMutator_InitContainers(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "new-image" return nil @@ -293,6 +372,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) @@ -307,6 +387,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { func TestMutator_Errors(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditPodSpec(func(_ *editors.PodSpecEditor) error { return errors.New("boom") }) @@ -331,6 +412,7 @@ func TestMutator_NilSafety(t *testing.T) { }, } m := NewMutator(cj) + m.BeginFeature() m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) m.EditContainers(selectors.AllContainers(), nil) @@ -364,6 +446,7 @@ func TestMutator_Order(t *testing.T) { var order []string m := NewMutator(cj) + m.BeginFeature() // Register in reverse order to verify execution order m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { order = append(order, "container") @@ -414,7 +497,6 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { } m := NewMutator(cj) - // Feature A m.BeginFeature() m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { @@ -462,6 +544,7 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" @@ -503,6 +586,7 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() // Register edit first m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { @@ -562,6 +646,7 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { func TestMutator_EditMetadata(t *testing.T) { cj := &batchv1.CronJob{} m := NewMutator(cj) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Labels = map[string]string{"cronjob": "label"} return nil @@ -594,6 +679,7 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { } m := NewMutator(cj) + m.BeginFeature() var executionOrder []string From 11378e54ee5b2e8d727858414b8e94e3cf665a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:08:01 +0000 Subject: [PATCH 28/32] refactor: remove field applicators and flavors from cronjob primitive Align with framework-wide SSA refactor: remove DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor, flavors.go, and update tests to use Object() output instead of empty structs in Mutate(). Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/cronjob/builder.go | 19 --- pkg/primitives/cronjob/builder_test.go | 38 ------ pkg/primitives/cronjob/flavors.go | 50 ------- pkg/primitives/cronjob/flavors_test.go | 145 --------------------- pkg/primitives/cronjob/resource.go | 18 +-- pkg/primitives/cronjob/resource_test.go | 165 ++++-------------------- 6 files changed, 29 insertions(+), 406 deletions(-) delete mode 100644 pkg/primitives/cronjob/flavors.go delete mode 100644 pkg/primitives/cronjob/flavors_test.go diff --git a/pkg/primitives/cronjob/builder.go b/pkg/primitives/cronjob/builder.go index 7b42abed..8169a7a0 100644 --- a/pkg/primitives/cronjob/builder.go +++ b/pkg/primitives/cronjob/builder.go @@ -34,7 +34,6 @@ func NewBuilder(cj *batchv1.CronJob) *Builder { base := generic.NewIntegrationBuilder[*batchv1.CronJob, *Mutator]( cj, identityFunc, - DefaultFieldApplicator, NewMutator, ) @@ -57,24 +56,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing CronJob in the cluster. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current *batchv1.CronJob, desired *batchv1.CronJob) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a reusable post-application "flavor" for -// the CronJob. -// -// If the provided flavor is nil, it is ignored. -func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { - b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*batchv1.CronJob](flavor)) - return b -} - // WithCustomOperationalStatus overrides the default logic for determining if the // CronJob is operational. func (b *Builder) WithCustomOperationalStatus( diff --git a/pkg/primitives/cronjob/builder_test.go b/pkg/primitives/cronjob/builder_test.go index 72f0d5a1..598c693c 100644 --- a/pkg/primitives/cronjob/builder_test.go +++ b/pkg/primitives/cronjob/builder_test.go @@ -92,44 +92,6 @@ func TestBuilder(t *testing.T) { assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) }) - t.Run("WithCustomFieldApplicator", func(t *testing.T) { - t.Parallel() - cj := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cronjob", - Namespace: "test-ns", - }, - } - applied := false - applicator := func(_ *batchv1.CronJob, _ *batchv1.CronJob) error { - applied = true - return nil - } - res, err := NewBuilder(cj). - WithCustomFieldApplicator(applicator). - Build() - require.NoError(t, err) - require.NotNil(t, res.base.CustomFieldApplicator) - _ = res.base.CustomFieldApplicator(nil, nil) - assert.True(t, applied) - }) - - t.Run("WithFieldApplicationFlavor", func(t *testing.T) { - t.Parallel() - cj := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cronjob", - Namespace: "test-ns", - }, - } - res, err := NewBuilder(cj). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(nil). - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) - }) - t.Run("WithCustomOperationalStatus", func(t *testing.T) { t.Parallel() cj := &batchv1.CronJob{ diff --git a/pkg/primitives/cronjob/flavors.go b/pkg/primitives/cronjob/flavors.go deleted file mode 100644 index 29ba4064..00000000 --- a/pkg/primitives/cronjob/flavors.go +++ /dev/null @@ -1,50 +0,0 @@ -package cronjob - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/flavors" - "github.com/sourcehawk/operator-component-framework/pkg/flavors/utils" - batchv1 "k8s.io/api/batch/v1" -) - -// FieldApplicationFlavor defines a function signature for applying "flavors" to a resource. -// A flavor typically preserves certain fields from the current (live) object after the -// baseline field application has occurred. -type FieldApplicationFlavor flavors.FieldApplicationFlavor[*batchv1.CronJob] - -// PreserveCurrentLabels ensures that any labels present on the current live -// CronJob but missing from the applied (desired) object are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentLabels(applied, current, desired *batchv1.CronJob) error { - return flavors.PreserveCurrentLabels[*batchv1.CronJob]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live CronJob but missing from the applied (desired) object are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentAnnotations(applied, current, desired *batchv1.CronJob) error { - return flavors.PreserveCurrentAnnotations[*batchv1.CronJob]()(applied, current, desired) -} - -// PreserveCurrentPodTemplateLabels ensures that any labels present on the -// current live CronJob's pod template but missing from the applied -// (desired) object's pod template are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentPodTemplateLabels(applied, current, _ *batchv1.CronJob) error { - applied.Spec.JobTemplate.Spec.Template.Labels = utils.PreserveMap( - applied.Spec.JobTemplate.Spec.Template.Labels, - current.Spec.JobTemplate.Spec.Template.Labels, - ) - return nil -} - -// PreserveCurrentPodTemplateAnnotations ensures that any annotations present -// on the current live CronJob's pod template but missing from the applied -// (desired) object's pod template are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentPodTemplateAnnotations(applied, current, _ *batchv1.CronJob) error { - applied.Spec.JobTemplate.Spec.Template.Annotations = utils.PreserveMap( - applied.Spec.JobTemplate.Spec.Template.Annotations, - current.Spec.JobTemplate.Spec.Template.Annotations, - ) - return nil -} diff --git a/pkg/primitives/cronjob/flavors_test.go b/pkg/primitives/cronjob/flavors_test.go deleted file mode 100644 index f08a2b85..00000000 --- a/pkg/primitives/cronjob/flavors_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package cronjob - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutate_OrderingAndFlavors(t *testing.T) { - desired := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cronjob", - Namespace: "test-ns", - Labels: map[string]string{"app": "desired"}, - }, - Spec: batchv1.CronJobSpec{ - Schedule: "*/5 * * * *", - }, - } - - t.Run("flavors run after baseline applicator", func(t *testing.T) { - current := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cronjob", - Namespace: "test-ns", - Labels: map[string]string{"extra": "preserved"}, - }, - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(PreserveCurrentLabels). - Build() - require.NoError(t, err) - - err = res.Mutate(current) - require.NoError(t, err) - - assert.Equal(t, "desired", current.Labels["app"]) - assert.Equal(t, "preserved", current.Labels["extra"]) - }) - - t.Run("flavors run in registration order", func(t *testing.T) { - current := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cronjob", - Namespace: "test-ns", - }, - } - - var order []string - flavor1 := func(_, _, _ *batchv1.CronJob) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *batchv1.CronJob) error { - order = append(order, "flavor2") - return nil - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor1). - WithFieldApplicationFlavor(flavor2). - Build() - require.NoError(t, err) - - err = res.Mutate(current) - require.NoError(t, err) - assert.Equal(t, []string{"flavor1", "flavor2"}, order) - }) - - t.Run("flavor error is returned with context", func(t *testing.T) { - current := &batchv1.CronJob{} - flavorErr := errors.New("boom") - flavor := func(_, _, _ *batchv1.CronJob) error { - return flavorErr - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor). - Build() - require.NoError(t, err) - - err = res.Mutate(current) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to apply field application flavor") - assert.True(t, errors.Is(err, flavorErr)) - }) -} - -func TestDefaultFlavors(t *testing.T) { - t.Run("PreserveCurrentLabels", func(t *testing.T) { - applied := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} - current := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current", "overlap": "current"}}} - - err := PreserveCurrentLabels(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "applied", applied.Labels["keep"]) - assert.Equal(t, "applied", applied.Labels["overlap"]) - assert.Equal(t, "current", applied.Labels["extra"]) - }) - - t.Run("PreserveCurrentAnnotations", func(t *testing.T) { - applied := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} - - err := PreserveCurrentAnnotations(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "applied", applied.Annotations["keep"]) - assert.Equal(t, "current", applied.Annotations["extra"]) - }) - - t.Run("PreserveCurrentPodTemplateLabels", func(t *testing.T) { - applied := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}}}} - current := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}}}}}} - - err := PreserveCurrentPodTemplateLabels(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "applied", applied.Spec.JobTemplate.Spec.Template.Labels["keep"]) - assert.Equal(t, "current", applied.Spec.JobTemplate.Spec.Template.Labels["extra"]) - }) - - t.Run("PreserveCurrentPodTemplateAnnotations", func(t *testing.T) { - applied := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}}}} - current := &batchv1.CronJob{Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}}}}}} - - err := PreserveCurrentPodTemplateAnnotations(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "applied", applied.Spec.JobTemplate.Spec.Template.Annotations["keep"]) - assert.Equal(t, "current", applied.Spec.JobTemplate.Spec.Template.Annotations["extra"]) - }) - - t.Run("handles nil maps safely", func(t *testing.T) { - applied := &batchv1.CronJob{} - current := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} - - err := PreserveCurrentLabels(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "current", applied.Labels["extra"]) - }) -} diff --git a/pkg/primitives/cronjob/resource.go b/pkg/primitives/cronjob/resource.go index 88c13b7c..a4f6afdc 100644 --- a/pkg/primitives/cronjob/resource.go +++ b/pkg/primitives/cronjob/resource.go @@ -8,18 +8,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), -// shared-controller fields (OwnerReferences, Finalizers), and the Status -// subresource from the original current object. -func DefaultFieldApplicator(current, desired *batchv1.CronJob) error { - original := current.DeepCopy() - *current = *desired.DeepCopy() - generic.PreserveServerManagedFields(current, original) - generic.PreserveStatus(current, original) - return nil -} - // Resource is a high-level abstraction for managing a Kubernetes CronJob within a controller's // reconciliation loop. // @@ -46,10 +34,8 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes CronJob into the desired state. // // The mutation process follows a specific order: -// 1. Core State: The current object is reset to the desired base state, or -// modified via a custom field applicator if one is configured. -// 2. Feature Mutations: All registered feature-based mutations are applied. -// 3. Suspension: If the resource is in a suspending state, the suspension +// 1. Feature Mutations: All registered feature-based mutations are applied. +// 2. Suspension: If the resource is in a suspending state, the suspension // logic (setting spec.suspend = true) is applied. func (r *Resource) Mutate(current client.Object) error { return r.base.Mutate(current) diff --git a/pkg/primitives/cronjob/resource_test.go b/pkg/primitives/cronjob/resource_test.go index 37e6840f..8a901762 100644 --- a/pkg/primitives/cronjob/resource_test.go +++ b/pkg/primitives/cronjob/resource_test.go @@ -70,11 +70,13 @@ func TestResource_Mutate(t *testing.T) { res, err := NewBuilder(desired).Build() require.NoError(t, err) - current := &batchv1.CronJob{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, "0 2 * * *", current.Spec.Schedule) - assert.Equal(t, "worker", current.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) + got := obj.(*batchv1.CronJob) + assert.Equal(t, "0 2 * * *", got.Spec.Schedule) + assert.Equal(t, "worker", got.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) } func TestResource_Mutate_WithMutation(t *testing.T) { @@ -91,11 +93,13 @@ func TestResource_Mutate_WithMutation(t *testing.T) { Build() require.NoError(t, err) - current := &batchv1.CronJob{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, "0 2 * * *", current.Spec.Schedule) - assert.Equal(t, "BAR", current.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[0].Value) + got := obj.(*batchv1.CronJob) + assert.Equal(t, "0 2 * * *", got.Spec.Schedule) + assert.Equal(t, "BAR", got.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[0].Value) } func TestResource_Mutate_FeatureOrdering(t *testing.T) { @@ -128,129 +132,12 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Build() require.NoError(t, err) - current := &batchv1.CronJob{} - require.NoError(t, res.Mutate(current)) - - assert.Equal(t, "v3", current.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) -} - -func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { - desired := newValidCronJob() - desired.Labels = map[string]string{"app": "test"} - - applicatorCalled := false - res, err := NewBuilder(desired). - WithCustomFieldApplicator(func(current, d *batchv1.CronJob) error { - applicatorCalled = true - current.Name = d.Name - current.Namespace = d.Namespace - current.Spec.Schedule = d.Spec.Schedule - return nil - }). - Build() - require.NoError(t, err) - - current := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"external": "label"}, - }, - } - require.NoError(t, res.Mutate(current)) - - assert.True(t, applicatorCalled) - assert.Equal(t, "0 2 * * *", current.Spec.Schedule) - assert.Equal(t, "label", current.Labels["external"]) - assert.NotContains(t, current.Labels, "app") - - t.Run("returns error", func(t *testing.T) { - res, err := NewBuilder(newValidCronJob()). - WithCustomFieldApplicator(func(_, _ *batchv1.CronJob) error { - return errors.New("applicator error") - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&batchv1.CronJob{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "applicator error") - }) -} - -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cj", - Namespace: "test-ns", - ResourceVersion: "12345", - UID: "abc-def", - Generation: 3, - OwnerReferences: []metav1.OwnerReference{ - {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, - }, - Finalizers: []string{"finalizer.example.com"}, - }, - } - desired := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cj", - Namespace: "test-ns", - Labels: map[string]string{"app": "test"}, - }, - Spec: batchv1.CronJobSpec{ - Schedule: "0 2 * * *", - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired spec and labels are applied - assert.Equal(t, "0 2 * * *", current.Spec.Schedule) - assert.Equal(t, "test", current.Labels["app"]) - - // Server-managed fields are preserved - assert.Equal(t, "12345", current.ResourceVersion) - assert.Equal(t, "abc-def", string(current.UID)) - assert.Equal(t, int64(3), current.Generation) - - // Shared-controller fields are preserved - assert.Len(t, current.OwnerReferences, 1) - assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) - assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) -} - -func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { - now := metav1.NewTime(time.Now()) - current := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cj", - Namespace: "test-ns", - }, - Status: batchv1.CronJobStatus{ - Active: []corev1.ObjectReference{{Name: "test-cj-12345"}}, - LastScheduleTime: &now, - }, - } - desired := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cj", - Namespace: "test-ns", - }, - Spec: batchv1.CronJobSpec{ - Schedule: "0 */6 * * *", - }, - } - - err := DefaultFieldApplicator(current, desired) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - // Desired spec is applied - assert.Equal(t, "0 */6 * * *", current.Spec.Schedule) - - // Status from the live object is preserved - assert.Len(t, current.Status.Active, 1) - assert.Equal(t, "test-cj-12345", current.Status.Active[0].Name) - assert.NotNil(t, current.Status.LastScheduleTime) + got := obj.(*batchv1.CronJob) + assert.Equal(t, "v3", got.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) } type mockHandlers struct { @@ -348,12 +235,13 @@ func TestResource_Suspend(t *testing.T) { err = res.Suspend() require.NoError(t, err) - current := cj.DeepCopy() - err = res.Mutate(current) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - require.NotNil(t, current.Spec.Suspend) - assert.True(t, *current.Spec.Suspend) + got := obj.(*batchv1.CronJob) + require.NotNil(t, got.Spec.Suspend) + assert.True(t, *got.Spec.Suspend) }) t.Run("uses custom mutation handler", func(t *testing.T) { @@ -375,14 +263,15 @@ func TestResource_Suspend(t *testing.T) { err = res.Suspend() require.NoError(t, err) - current := cj.DeepCopy() - err = res.Mutate(current) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*batchv1.CronJob) m.AssertExpectations(t) - require.NotNil(t, current.Spec.Suspend) - assert.True(t, *current.Spec.Suspend) - assert.Equal(t, "0 0 31 2 *", current.Spec.Schedule) + require.NotNil(t, got.Spec.Suspend) + assert.True(t, *got.Spec.Suspend) + assert.Equal(t, "0 0 31 2 *", got.Spec.Schedule) }) } From 16498f4b0a757ab1ccb0f9afe69b6262167dd7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:53:23 +0000 Subject: [PATCH 29/32] fix: update test and example to match refactored ApplyMutations signature The previous refactor changed ApplyMutations to remove field applicators but the test and cronjob example were not updated, causing CI failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cronjob-primitive/resources/cronjob.go | 8 ++----- internal/generic/mutate_helper_test.go | 23 ++++++++----------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/examples/cronjob-primitive/resources/cronjob.go b/examples/cronjob-primitive/resources/cronjob.go index 3210d70d..12cd20dc 100644 --- a/examples/cronjob-primitive/resources/cronjob.go +++ b/examples/cronjob-primitive/resources/cronjob.go @@ -59,11 +59,7 @@ func NewCronJobResource(owner *app.ExampleApp) (component.Resource, error) { builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics)) - // 4. Configure flavors. - builder.WithFieldApplicationFlavor(cronjob.PreserveCurrentLabels) - builder.WithFieldApplicationFlavor(cronjob.PreserveCurrentAnnotations) - - // 5. Data extraction (optional). + // 4. Data extraction (optional). builder.WithDataExtractor(func(cj batchv1.CronJob) error { fmt.Printf("Reconciling CronJob: %s, schedule: %s\n", cj.Name, cj.Spec.Schedule) @@ -76,6 +72,6 @@ func NewCronJobResource(owner *app.ExampleApp) (component.Resource, error) { return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index a2a1bfae..fd959bc5 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -90,14 +90,6 @@ func TestApplyMutationsOrder_MultipleMutations(t *testing.T) { current := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, } - desired := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - } - - defaultApplicator := func(_, _ *corev1.ConfigMap) error { - recorder.record("defaultApplicator") - return nil - } newMutator := func(_ *corev1.ConfigMap) *recordingMutator { recorder.record("newMutator") @@ -131,22 +123,22 @@ func TestApplyMutationsOrder_MultipleMutations(t *testing.T) { }, } + suspender := func(_ *recordingMutator) error { + recorder.record("suspender") + return nil + } + _, err := ApplyMutations[*corev1.ConfigMap, *recordingMutator]( current, - desired, - defaultApplicator, - nil, - nil, newMutator, mutations, - nil, + suspender, ) require.NoError(t, err) // Each mutation gets its own plan via BeginFeature. expectedOrder := []string{ - "defaultApplicator", "newMutator", "mutator.BeginFeature", "mutation1", @@ -155,6 +147,9 @@ func TestApplyMutationsOrder_MultipleMutations(t *testing.T) { "mutator.BeginFeature", "mutation3", "mutator.Apply", + "mutator.BeginFeature", + "suspender", + "mutator.Apply", } require.Len(t, recorder.events, len(expectedOrder), "events: %v", recorder.events) From 71434fc2861bdaba0ad492146178a145997b5d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:00:59 +0000 Subject: [PATCH 30/32] fix linter issues --- e2e/framework/crd.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/e2e/framework/crd.go b/e2e/framework/crd.go index 7ecbe2ad..fa700fae 100644 --- a/e2e/framework/crd.go +++ b/e2e/framework/crd.go @@ -64,10 +64,7 @@ func (t *TestApp) DeepCopy() *TestApp { // DeepCopyObject implements runtime.Object. func (t *TestApp) DeepCopyObject() runtime.Object { - if c := t.DeepCopy(); c != nil { - return c - } - return nil + return t.DeepCopy() } // TestAppList contains a list of TestApp. @@ -104,10 +101,7 @@ func (t *TestAppList) DeepCopy() *TestAppList { // DeepCopyObject implements runtime.Object. func (t *TestAppList) DeepCopyObject() runtime.Object { - if c := t.DeepCopy(); c != nil { - return c - } - return nil + return t.DeepCopy() } var ( From 4b384edbc8b5f1f3fb7ffe9cf60ccd94368438e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:05:00 +0000 Subject: [PATCH 31/32] docs: remove references to non-existent field applicator and flavor APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cronjob documentation and example README referenced DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor, and PreserveCurrentLabels — none of which exist in the codebase. The framework uses SSA-based apply. Updated docs to accurately reflect the actual behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/cronjob.md | 17 ++++++----------- examples/cronjob-primitive/README.md | 1 - 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md index f83db4e4..0e3aae12 100644 --- a/docs/primitives/cronjob.md +++ b/docs/primitives/cronjob.md @@ -11,16 +11,12 @@ mutation API for managing the CronJob schedule, job template, pod spec, and cont | **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) | | **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | | **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | -## Default Field Application +## Server-Side Apply -`DefaultFieldApplicator` replaces the current CronJob with a deep copy of the desired object, then restores -server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the -Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data -written by the API server or other controllers. - -Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. +The CronJob primitive reconciles resources using **Server-Side Apply** (SSA). Only fields declared by the operator are +sent; server-managed defaults, fields set by other controllers, and values written by webhooks are left untouched. Field +ownership is tracked automatically by the Kubernetes API server. ## Building a CronJob Primitive @@ -50,7 +46,6 @@ base := &batchv1.CronJob{ } resource, err := cronjob.NewBuilder(base). - WithFieldApplicationFlavor(cronjob.PreserveCurrentLabels). WithMutation(MyScheduleMutation(owner.Spec.Version)). Build() ``` @@ -232,8 +227,8 @@ from creating new Job objects. Existing active jobs continue to run. | `Suspending` | `spec.suspend == true` but active jobs still running | | `Suspending` | Waiting for suspend flag to be applied | -On unsuspend, the `DefaultFieldApplicator` restores the desired state (without `spec.suspend = true`), allowing the -CronJob to resume scheduling. +On unsuspend, the desired state (without `spec.suspend = true`) is applied via SSA, allowing the CronJob to resume +scheduling. The CronJob is never deleted on suspend (`DeleteOnSuspend = false`). diff --git a/examples/cronjob-primitive/README.md b/examples/cronjob-primitive/README.md index d20ab809..b6996d4d 100644 --- a/examples/cronjob-primitive/README.md +++ b/examples/cronjob-primitive/README.md @@ -6,7 +6,6 @@ manage a Kubernetes CronJob as a component of a larger application, utilizing fe - **Base Construction**: Initializing a CronJob with a schedule, job template, and containers. - **Feature Mutations**: Applying conditional changes (tracing env vars, metrics annotations, version-based image updates) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools. - **Suspension**: Suspending the CronJob by setting `spec.suspend = true`. - **Data Extraction**: Harvesting information from the reconciled resource. From f9622244b55c1d0e87c27e578e3d28e99f963889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:28:27 +0000 Subject: [PATCH 32/32] fix: panic with clear message when BeginFeature not called before mutations Add requireActive() guard to all mutator registration methods so that callers get an explicit panic message instead of a nil pointer dereference if they forget to call BeginFeature() first. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/cronjob/mutator.go | 17 +++++++++++++++++ pkg/primitives/cronjob/mutator_test.go | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go index 38feb604..aea741b2 100644 --- a/pkg/primitives/cronjob/mutator.go +++ b/pkg/primitives/cronjob/mutator.go @@ -62,6 +62,12 @@ func (m *Mutator) BeginFeature() { m.active = &m.plans[len(m.plans)-1] } +func (m *Mutator) requireActive() { + if m.active == nil { + panic("cronjob.Mutator: BeginFeature() must be called before registering mutations") + } +} + // EditObjectMetadata records a mutation for the CronJob's own metadata. // // If the edit function is nil, the registration is ignored. @@ -69,6 +75,7 @@ func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) if edit == nil { return } + m.requireActive() m.active.cronjobMetadataEdits = append(m.active.cronjobMetadataEdits, edit) } @@ -79,6 +86,7 @@ func (m *Mutator) EditCronJobSpec(edit func(*editors.CronJobSpecEditor) error) { if edit == nil { return } + m.requireActive() m.active.cronjobSpecEdits = append(m.active.cronjobSpecEdits, edit) } @@ -89,6 +97,7 @@ func (m *Mutator) EditJobSpec(edit func(*editors.JobSpecEditor) error) { if edit == nil { return } + m.requireActive() m.active.jobSpecEdits = append(m.active.jobSpecEdits, edit) } @@ -99,6 +108,7 @@ func (m *Mutator) EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) e if edit == nil { return } + m.requireActive() m.active.podTemplateMetadataEdits = append(m.active.podTemplateMetadataEdits, edit) } @@ -109,6 +119,7 @@ func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { if edit == nil { return } + m.requireActive() m.active.podSpecEdits = append(m.active.podSpecEdits, edit) } @@ -121,6 +132,7 @@ func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func if selector == nil || edit == nil { return } + m.requireActive() m.active.containerEdits = append(m.active.containerEdits, containerEdit{ selector: selector, edit: edit, @@ -136,6 +148,7 @@ func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit if selector == nil || edit == nil { return } + m.requireActive() m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ selector: selector, edit: edit, @@ -145,6 +158,7 @@ func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit // EnsureContainer records that a regular container must be present in the CronJob. // If a container with the same name exists, it is replaced; otherwise, it is appended. func (m *Mutator) EnsureContainer(container corev1.Container) { + m.requireActive() m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ name: container.Name, container: &container, @@ -153,6 +167,7 @@ func (m *Mutator) EnsureContainer(container corev1.Container) { // RemoveContainer records that a regular container should be removed by name. func (m *Mutator) RemoveContainer(name string) { + m.requireActive() m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ name: name, container: nil, @@ -169,6 +184,7 @@ func (m *Mutator) RemoveContainers(names []string) { // EnsureInitContainer records that an init container must be present in the CronJob. // If an init container with the same name exists, it is replaced; otherwise, it is appended. func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.requireActive() m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ name: container.Name, container: &container, @@ -177,6 +193,7 @@ func (m *Mutator) EnsureInitContainer(container corev1.Container) { // RemoveInitContainer records that an init container should be removed by name. func (m *Mutator) RemoveInitContainer(name string) { + m.requireActive() m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ name: name, container: nil, diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go index 982e5418..a227baa2 100644 --- a/pkg/primitives/cronjob/mutator_test.go +++ b/pkg/primitives/cronjob/mutator_test.go @@ -722,3 +722,16 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { } assert.Equal(t, expectedOrder, executionOrder) } + +func TestMutator_PanicsWithoutBeginFeature(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + + assert.PanicsWithValue(t, "cronjob.Mutator: BeginFeature() must be called before registering mutations", func() { + m.EditObjectMetadata(func(*editors.ObjectMetaEditor) error { return nil }) + }) + + assert.PanicsWithValue(t, "cronjob.Mutator: BeginFeature() must be called before registering mutations", func() { + m.EnsureContainer(corev1.Container{Name: "test"}) + }) +}