From 59249321c773a3832030dc8f7f4e9681619e2381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:40:51 +0000 Subject: [PATCH 01/19] Add Job primitive: builder, resource, handlers, flavors, mutator tests Implements the Job primitive using the Task lifecycle (Completable, Suspendable) from internal/generic. Includes default handlers for completion status, suspension via Job.Spec.Suspend, and deletion on suspend. Follows the same pod-template mutation pattern as Deployment. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/builder.go | 190 ++++++++++++ pkg/primitives/job/builder_test.go | 251 ++++++++++++++++ pkg/primitives/job/flavors.go | 44 +++ pkg/primitives/job/flavors_test.go | 139 +++++++++ pkg/primitives/job/handlers.go | 124 ++++++++ pkg/primitives/job/handlers_test.go | 171 +++++++++++ pkg/primitives/job/mutator_test.go | 433 ++++++++++++++++++++++++++++ pkg/primitives/job/resource.go | 128 ++++++++ 8 files changed, 1480 insertions(+) create mode 100644 pkg/primitives/job/builder.go create mode 100644 pkg/primitives/job/builder_test.go create mode 100644 pkg/primitives/job/flavors.go create mode 100644 pkg/primitives/job/flavors_test.go create mode 100644 pkg/primitives/job/handlers.go create mode 100644 pkg/primitives/job/handlers_test.go create mode 100644 pkg/primitives/job/mutator_test.go create mode 100644 pkg/primitives/job/resource.go diff --git a/pkg/primitives/job/builder.go b/pkg/primitives/job/builder.go new file mode 100644 index 00000000..ee552402 --- /dev/null +++ b/pkg/primitives/job/builder.go @@ -0,0 +1,190 @@ +package job + +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 Job 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.TaskBuilder[*batchv1.Job, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Job object. +// +// The Job 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 Job must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(job *batchv1.Job) *Builder { + identityFunc := func(j *batchv1.Job) string { + return fmt.Sprintf("batch/v1/Job/%s/%s", j.Namespace, j.Name) + } + + base := generic.NewTaskBuilder[*batchv1.Job, *Mutator]( + job, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the Job. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// They are typically used by Features to inject environment variables, +// arguments, or other configuration into the Job's containers. +// +// Since mutations are often version-gated, the provided feature.Mutation +// should contain the logic to determine if and how the mutation is applied +// based on the component's current version or configuration. +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 Job in the cluster. +// +// There is a default field applicator (DefaultFieldApplicator) that overwrites +// the entire spec of the current object with the desired state. Using a custom +// applicator is necessary when external controllers manage specific fields that +// should not be overwritten. +// +// If a custom applicator is set, it overrides the default baseline application +// logic. Post-application flavors and mutations are still applied afterward. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current *batchv1.Job, desired *batchv1.Job) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a reusable post-application "flavor" for +// the Job. +// +// Flavors are applied in the order they are registered, after the baseline field +// applicator (default or custom) has already run. They are typically used to +// preserve selected live fields from the current object that should not be +// overwritten by the desired state. +// +// If the provided flavor is nil, it is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*batchv1.Job](flavor)) + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining if the +// Job has completed, is running, or has failed. +// +// The default behavior uses DefaultConvergingStatusHandler, which checks the Job's +// status conditions. Use this method if your Job requires more complex checks, +// such as waiting for specific annotations or external signals. +// +// If you want to augment the default behavior, you can call DefaultConvergingStatusHandler +// within your custom handler. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *batchv1.Job) (concepts.CompletionStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which reports the +// progress based on the Job's Suspend field and active pod count. Use this if +// your custom suspension strategy involves other measurable states. +// +// If you want to augment the default behavior, you can call DefaultSuspensionStatusHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*batchv1.Job) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Job should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which sets the +// Job's Suspend field to true. You might override this if you want to suspend +// the Job by other means. +// +// If you want to augment the default behavior, you can call DefaultSuspendMutationHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the Job when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns true, +// meaning Jobs are deleted during suspension. Return false from this handler if +// you want the Job to remain in the cluster when suspended. +// +// If you want to augment the default behavior, you can call DefaultDeleteOnSuspendHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*batchv1.Job) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// Job after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields (like completion status +// or pod names) and making them available to other components or resources via +// the framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(batchv1.Job) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(j *batchv1.Job) error { + return extractor(*j) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base Job object was provided. +// - The Job 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/job/builder_test.go b/pkg/primitives/job/builder_test.go new file mode 100644 index 00000000..db213759 --- /dev/null +++ b/pkg/primitives/job/builder_test.go @@ -0,0 +1,251 @@ +package job + +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 + job *batchv1.Job + expectedErr string + }{ + { + name: "nil job", + job: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + job: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + job: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid job", + job: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.job).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/Job/test-ns/test-job", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(job). + 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() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_ *batchv1.Job, _ *batchv1.Job) error { + applied = true + return nil + } + res, err := NewBuilder(job). + 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() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(job). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) + }) + + t.Run("WithCustomConvergeStatus", func(t *testing.T) { + t.Parallel() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *batchv1.Job) (concepts.CompletionStatusWithReason, error) { + return concepts.CompletionStatusWithReason{Status: concepts.CompletionStatusCompleted}, nil + } + res, err := NewBuilder(job). + WithCustomConvergeStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.ConvergingStatusHandler) + status, err := res.base.ConvergingStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.CompletionStatusCompleted, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + handler := func(_ *batchv1.Job) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(job). + 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() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(job). + 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() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + handler := func(_ *batchv1.Job) bool { + return false + } + res, err := NewBuilder(job). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.False(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ batchv1.Job) error { + called = true + return nil + } + res, err := NewBuilder(job). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&batchv1.Job{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(job). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/job/flavors.go b/pkg/primitives/job/flavors.go new file mode 100644 index 00000000..254ff698 --- /dev/null +++ b/pkg/primitives/job/flavors.go @@ -0,0 +1,44 @@ +package job + +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.Job] + +// PreserveCurrentLabels ensures that any labels present on the current live +// Job 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.Job) error { + return flavors.PreserveCurrentLabels[*batchv1.Job]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live Job 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.Job) error { + return flavors.PreserveCurrentAnnotations[*batchv1.Job]()(applied, current, desired) +} + +// PreserveCurrentPodTemplateLabels ensures that any labels present on the +// current live Job'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.Job) error { + applied.Spec.Template.Labels = utils.PreserveMap(applied.Spec.Template.Labels, current.Spec.Template.Labels) + return nil +} + +// PreserveCurrentPodTemplateAnnotations ensures that any annotations present +// on the current live Job'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.Job) error { + applied.Spec.Template.Annotations = utils.PreserveMap(applied.Spec.Template.Annotations, current.Spec.Template.Annotations) + return nil +} diff --git a/pkg/primitives/job/flavors_test.go b/pkg/primitives/job/flavors_test.go new file mode 100644 index 00000000..edfb1123 --- /dev/null +++ b/pkg/primitives/job/flavors_test.go @@ -0,0 +1,139 @@ +package job + +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.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{"app": "desired"}, + }, + } + + t.Run("flavors run after baseline applicator", func(t *testing.T) { + current := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + 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.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + } + + var order []string + flavor1 := func(_, _, _ *batchv1.Job) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *batchv1.Job) 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.Job{} + flavorErr := errors.New("boom") + flavor := func(_, _, _ *batchv1.Job) 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.Job{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} + current := &batchv1.Job{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.Job{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &batchv1.Job{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.Job{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}} + current := &batchv1.Job{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.Template.Labels["keep"]) + assert.Equal(t, "current", applied.Spec.Template.Labels["extra"]) + }) + + t.Run("PreserveCurrentPodTemplateAnnotations", func(t *testing.T) { + applied := &batchv1.Job{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}} + current := &batchv1.Job{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.Template.Annotations["keep"]) + assert.Equal(t, "current", applied.Spec.Template.Annotations["extra"]) + }) + + t.Run("handles nil maps safely", func(t *testing.T) { + applied := &batchv1.Job{} + current := &batchv1.Job{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/job/handlers.go b/pkg/primitives/job/handlers.go new file mode 100644 index 00000000..6d05b92b --- /dev/null +++ b/pkg/primitives/job/handlers.go @@ -0,0 +1,124 @@ +package job + +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" +) + +// DefaultConvergingStatusHandler is the default logic for determining if a Job has completed or is still running. +// +// It checks the Job's status conditions for Complete or Failed, and reports the +// appropriate CompletionStatus based on the current state. +// +// 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, job *batchv1.Job, +) (concepts.CompletionStatusWithReason, error) { + for _, cond := range job.Status.Conditions { + if cond.Type == batchv1.JobComplete && cond.Status == "True" { + return concepts.CompletionStatusWithReason{ + Status: concepts.CompletionStatusCompleted, + Reason: "Job completed successfully", + }, nil + } + if cond.Type == batchv1.JobFailed && cond.Status == "True" { + reason := "Job failed" + if cond.Message != "" { + reason = fmt.Sprintf("Job failed: %s", cond.Message) + } + return concepts.CompletionStatusWithReason{ + Status: concepts.CompletionStatusFailing, + Reason: reason, + }, nil + } + } + + if job.Status.Active > 0 { + return concepts.CompletionStatusWithReason{ + Status: concepts.CompletionStatusRunning, + Reason: fmt.Sprintf("Job has %d active pod(s)", job.Status.Active), + }, nil + } + + switch op { + case concepts.ConvergingOperationCreated: + return concepts.CompletionStatusWithReason{ + Status: concepts.CompletionStatusPending, + Reason: "Job was just created, waiting for pods to be scheduled", + }, nil + default: + return concepts.CompletionStatusWithReason{ + Status: concepts.CompletionStatusPending, + Reason: "Job is waiting for pods to be scheduled", + }, nil + } +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the Job +// when the parent component is suspended. +// +// It always returns true, meaning the Job is deleted from the cluster during suspension. +// Jobs cannot be meaningfully scaled to zero like Deployments, so deletion is the +// standard approach for suspending a task resource. +// +// This function is used as the default handler by the Resource if no custom handler is +// registered via Builder.WithCustomSuspendDeletionDecision. It can be reused within +// custom handlers. +func DefaultDeleteOnSuspendHandler(_ *batchv1.Job) bool { + return true +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a Job when +// the component is suspended. +// +// It sets the Job's Suspend field to true, which prevents the Job controller from +// creating new pods while allowing existing pods to complete. +// +// This function is used as the default handler by the Resource if no custom handler is +// registered via Builder.WithCustomSuspendMutation. It can be reused within custom handlers. +func DefaultSuspendMutationHandler(mutator *Mutator) error { + mutator.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.Raw().Suspend = boolPtr(true) + return nil + }) + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It reports whether the Job has been successfully suspended by checking if the +// Job's Suspend field is true and there are no active pods. +// +// This function is used as the default handler by the Resource if no custom handler is +// registered via Builder.WithCustomSuspendStatus. It can be reused within custom handlers. +func DefaultSuspensionStatusHandler(job *batchv1.Job) (concepts.SuspensionStatusWithReason, error) { + isSuspended := job.Spec.Suspend != nil && *job.Spec.Suspend + + if isSuspended && job.Status.Active == 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Job is suspended", + }, nil + } + + if isSuspended { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("Job is suspending, %d pod(s) still active", job.Status.Active), + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: "Waiting for Job suspend field to be applied", + }, nil +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/pkg/primitives/job/handlers_test.go b/pkg/primitives/job/handlers_test.go new file mode 100644 index 00000000..d04c0d60 --- /dev/null +++ b/pkg/primitives/job/handlers_test.go @@ -0,0 +1,171 @@ +package job + +import ( + "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" + corev1 "k8s.io/api/core/v1" +) + +func TestDefaultConvergingStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + job *batchv1.Job + wantStatus concepts.CompletionStatus + wantReason string + }{ + { + name: "completed", + op: concepts.ConvergingOperationUpdated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + {Type: batchv1.JobComplete, Status: corev1.ConditionTrue}, + }, + }, + }, + wantStatus: concepts.CompletionStatusCompleted, + wantReason: "Job completed successfully", + }, + { + name: "failed", + op: concepts.ConvergingOperationUpdated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Message: "BackoffLimitExceeded"}, + }, + }, + }, + wantStatus: concepts.CompletionStatusFailing, + wantReason: "Job failed: BackoffLimitExceeded", + }, + { + name: "failed without message", + op: concepts.ConvergingOperationUpdated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue}, + }, + }, + }, + wantStatus: concepts.CompletionStatusFailing, + wantReason: "Job failed", + }, + { + name: "running", + op: concepts.ConvergingOperationUpdated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{ + Active: 2, + }, + }, + wantStatus: concepts.CompletionStatusRunning, + wantReason: "Job has 2 active pod(s)", + }, + { + name: "pending after creation", + op: concepts.ConvergingOperationCreated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{}, + }, + wantStatus: concepts.CompletionStatusPending, + wantReason: "Job was just created, waiting for pods to be scheduled", + }, + { + name: "pending after update", + op: concepts.ConvergingOperationUpdated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{}, + }, + wantStatus: concepts.CompletionStatusPending, + wantReason: "Job is waiting for pods to be scheduled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.job) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + job := &batchv1.Job{} + assert.True(t, DefaultDeleteOnSuspendHandler(job)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + job := &batchv1.Job{} + mutator := NewMutator(job) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + require.NotNil(t, job.Spec.Suspend) + assert.True(t, *job.Spec.Suspend) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + job *batchv1.Job + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended", + job: &batchv1.Job{ + Spec: batchv1.JobSpec{ + Suspend: boolPtr(true), + }, + Status: batchv1.JobStatus{ + Active: 0, + }, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "Job is suspended", + }, + { + name: "suspending with active pods", + job: &batchv1.Job{ + Spec: batchv1.JobSpec{ + Suspend: boolPtr(true), + }, + Status: batchv1.JobStatus{ + Active: 2, + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Job is suspending, 2 pod(s) still active", + }, + { + name: "waiting for suspend field", + job: &batchv1.Job{ + Spec: batchv1.JobSpec{}, + Status: batchv1.JobStatus{ + Active: 1, + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for Job suspend field to be applied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultSuspensionStatusHandler(tt.job) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} diff --git a/pkg/primitives/job/mutator_test.go b/pkg/primitives/job/mutator_test.go new file mode 100644 index 00000000..7e9d4d59 --- /dev/null +++ b/pkg/primitives/job/mutator_test.go @@ -0,0 +1,433 @@ +package job + +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) { + job := &batchv1.Job{} + m := NewMutator(job) + assert.NotNil(t, m) + assert.Equal(t, job, m.current) +} + +func TestMutator_EnvVars(t *testing.T) { + job := &batchv1.Job{ + 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(job) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) + m.RemoveContainerEnvVar("REMOVE") + + err := m.Apply() + require.NoError(t, err) + + env := job.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_EditContainers(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + } + + m := NewMutator(job) + 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", job.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", job.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", job.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", job.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + job := &batchv1.Job{} + m := NewMutator(job) + 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", job.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditJobSpec(t *testing.T) { + job := &batchv1.Job{} + m := NewMutator(job) + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(5) + e.SetCompletions(3) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + require.NotNil(t, job.Spec.BackoffLimit) + assert.Equal(t, int32(5), *job.Spec.BackoffLimit) + require.NotNil(t, job.Spec.Completions) + assert.Equal(t, int32(3), *job.Spec.Completions) +} + +func TestMutator_EditMetadata(t *testing.T) { + job := &batchv1.Job{} + m := NewMutator(job) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"job": "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", job.Labels["job"]) + assert.Equal(t, "ann", job.Spec.Template.Annotations["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + job := &batchv1.Job{} + m := NewMutator(job) + 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_Order(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"orig": "label"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + + var order []string + + m := NewMutator(job) + // Register in reverse order to verify fixed category ordering + 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.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "jobmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"jobmeta", "jobspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) +} + +func TestMutator_ContainerPresence(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + } + + m := NewMutator(job) + 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) + + require.Len(t, job.Spec.Template.Spec.Containers, 2) + assert.Equal(t, "app", job.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", job.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "new-container", job.Spec.Template.Spec.Containers[1].Name) + assert.Equal(t, "new-image", job.Spec.Template.Spec.Containers[1].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + } + + m := NewMutator(job) + m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) + m.RemoveInitContainers([]string{"init-1"}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, job.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-2", job.Spec.Template.Spec.InitContainers[0].Name) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + } + + m := NewMutator(job) + + // First edit renames the container + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Second edit should still match using "app" selector because of snapshot + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + // Third edit targeting "app-v2" 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", job.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", job.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_NilSafety(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + m := NewMutator(job) + + // These should all be no-ops and not panic + 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.EditJobSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + } + + m := NewMutator(job) + + // Feature A: sets backoff to 2, image to v2 + m.beginFeature() + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(2) + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B: sets backoff to 3, image to v3 + m.beginFeature() + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + 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) + + // Feature B should win + assert.Equal(t, int32(3), *job.Spec.BackoffLimit) + assert.Equal(t, "v3", job.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(job) + + // 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", job.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", job.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_PresenceBeforeEdit(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(job) + + // 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) + + // It should work because presence happens before edits in Apply() + require.Len(t, job.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "edited-image", job.Spec.Template.Spec.Containers[0].Image) +} diff --git a/pkg/primitives/job/resource.go b/pkg/primitives/job/resource.go new file mode 100644 index 00000000..7c0a4531 --- /dev/null +++ b/pkg/primitives/job/resource.go @@ -0,0 +1,128 @@ +package job + +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.Job) error { + *current = *desired.DeepCopy() + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes Job 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.Completable: for run-to-completion tracking. +// - component.Suspendable: for controlled deactivation (suspend or delete). +// - component.DataExtractable: for exporting information after successful reconciliation. +// +// This resource handles the lifecycle of a Job, including initial creation, +// updates via feature mutations, and completion status monitoring. +type Resource struct { + base *generic.TaskResource[*batchv1.Job, *Mutator] +} + +// Identity returns a unique identifier for the Job in the format +// "batch/v1/Job//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific Job from other resources +// managed by the same component. +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a copy of the underlying Kubernetes Job object. +// +// The returned object implements the client.Object interface, making it +// fully compatible with controller-runtime's Client for operations like +// Get, Create, Update, and Patch. +// +// This method is called by the framework to obtain the current state +// of the resource before applying mutations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes Job 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, +// allowing for granular, version-gated changes to the Job. +// 3. Suspension: If the resource is in a suspending state, the suspension +// logic (e.g., setting suspend=true) is applied. +// +// This method is invoked by the framework during the "Update" phase of +// reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates if the Job has completed, is still running, or has failed. +// +// By default, it uses DefaultConvergingStatusHandler, which checks the Job's status +// conditions for Complete or Failed. +// +// The return value includes a descriptive status (Completed, TaskRunning, TaskPending, +// or TaskFailing) and a human-readable reason, which are used to update the component's +// conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.CompletionStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the Job should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns true, meaning +// the Job is deleted during suspension. Jobs cannot be meaningfully scaled to zero +// like Deployments, so deletion is the standard approach. +// +// A custom decision handler can be registered via the Builder to change this +// behavior based on the current state of the Job. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the Job. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler to set the Job's +// Suspend field to true, which prevents new pods from being created. +// +// This is typically called by the framework when a component's .spec.suspended +// field is set 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 whether the +// Job has been successfully suspended by checking if the Suspend field is true +// and there are no active pods. The framework uses this to determine when the +// component has reached a fully suspended state. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled Job. +// +// This is called by the framework after a successful reconciliation of the +// resource. It allows the component to export details (like completion status +// or generated names) that might be needed by other resources or higher-level +// controllers. +// +// Data extractors are provided with a deep copy of the current Job to +// prevent accidental mutations during the extraction process. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From 0cf92c96192a7e5011b2415c85e8db79fdf243c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:40:56 +0000 Subject: [PATCH 02/19] Add JobSpecEditor for typed Job spec mutations Provides SetCompletions, SetParallelism, SetBackoffLimit, SetActiveDeadlineSeconds, SetTTLSecondsAfterFinished, and SetCompletionMode methods with full test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/jobspec.go | 52 ++++++++++++++++++++++ pkg/mutation/editors/jobspec_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 pkg/mutation/editors/jobspec.go create mode 100644 pkg/mutation/editors/jobspec_test.go diff --git a/pkg/mutation/editors/jobspec.go b/pkg/mutation/editors/jobspec.go new file mode 100644 index 00000000..eccf8970 --- /dev/null +++ b/pkg/mutation/editors/jobspec.go @@ -0,0 +1,52 @@ +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 number of pods running in parallel. +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 maximum duration in seconds for the job to run. +func (e *JobSpecEditor) SetActiveDeadlineSeconds(seconds int64) { + e.spec.ActiveDeadlineSeconds = &seconds +} + +// SetTTLSecondsAfterFinished sets how long (in seconds) to keep the job after it finishes. +func (e *JobSpecEditor) SetTTLSecondsAfterFinished(seconds int32) { + e.spec.TTLSecondsAfterFinished = &seconds +} + +// SetCompletionMode sets the completion mode of the job (e.g. NonIndexed or Indexed). +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..3584d57b --- /dev/null +++ b/pkg/mutation/editors/jobspec_test.go @@ -0,0 +1,65 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" +) + +func TestJobSpecEditor(t *testing.T) { + t.Run("SetCompletions", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + e.SetCompletions(5) + require.NotNil(t, spec.Completions) + assert.Equal(t, int32(5), *spec.Completions) + }) + + t.Run("SetParallelism", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + e.SetParallelism(3) + require.NotNil(t, spec.Parallelism) + assert.Equal(t, int32(3), *spec.Parallelism) + }) + + t.Run("SetBackoffLimit", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + e.SetBackoffLimit(6) + require.NotNil(t, spec.BackoffLimit) + assert.Equal(t, int32(6), *spec.BackoffLimit) + }) + + t.Run("SetActiveDeadlineSeconds", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + e.SetActiveDeadlineSeconds(300) + require.NotNil(t, spec.ActiveDeadlineSeconds) + assert.Equal(t, int64(300), *spec.ActiveDeadlineSeconds) + }) + + t.Run("SetTTLSecondsAfterFinished", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + e.SetTTLSecondsAfterFinished(60) + require.NotNil(t, spec.TTLSecondsAfterFinished) + assert.Equal(t, int32(60), *spec.TTLSecondsAfterFinished) + }) + + t.Run("SetCompletionMode", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + e.SetCompletionMode(batchv1.IndexedCompletion) + require.NotNil(t, spec.CompletionMode) + assert.Equal(t, batchv1.IndexedCompletion, *spec.CompletionMode) + }) + + t.Run("Raw returns pointer to spec", func(t *testing.T) { + spec := &batchv1.JobSpec{} + e := NewJobSpecEditor(spec) + assert.Same(t, spec, e.Raw()) + }) +} From a17d2b9b1273116ef2e01bf7ebe448c777dcdb2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:41:01 +0000 Subject: [PATCH 03/19] Add Job mutator with plan-and-apply pattern Implements the Mutator for Job resources following the same pattern as the Deployment mutator. Supports editing job metadata, job spec, pod template metadata, pod spec, and container presence/edits with snapshot semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/mutator.go | 356 ++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 pkg/primitives/job/mutator.go diff --git a/pkg/primitives/job/mutator.go b/pkg/primitives/job/mutator.go new file mode 100644 index 00000000..ded670c8 --- /dev/null +++ b/pkg/primitives/job/mutator.go @@ -0,0 +1,356 @@ +// Package job provides a builder and resource for managing Kubernetes Jobs. +package job + +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 job Mutator +// only if its associated feature gate 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 { + jobMetadataEdits []func(*editors.ObjectMetaEditor) 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 Job. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then applied +// to the Job 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. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + current *batchv1.Job + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Job. +func NewMutator(current *batchv1.Job) *Mutator { + m := &Mutator{current: current} + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Job's own metadata. +// +// Metadata edits are applied before all other categories within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.jobMetadataEdits = append(m.active.jobMetadataEdits, edit) +} + +// EditJobSpec records a mutation for the Job's top-level spec. +// +// Job spec edits are applied after metadata edits but before pod template edits +// within the same feature. A nil edit function 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 Job's pod template metadata. +// +// Pod template metadata edits are applied after job spec edits but before pod spec +// edits within the same feature. A nil edit function 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 Job's pod spec. +// +// Pod spec edits are applied after pod template metadata edits but before container +// edits within the same feature. A nil edit function 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. +// +// Edits are applied after container presence operations within the same feature. +// Selector matching is evaluated against a snapshot taken after the current +// feature's container presence operations have been 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. +// +// Edits are applied after init container presence operations within the same feature. +// Selector matching is evaluated against a snapshot taken after the current +// feature's init container presence operations have been 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 Job. +// 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 Job. +// 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 Job. +// +// This is a convenience wrapper over EditContainers. +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 Job. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerEnvVar(name string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVar(name) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying Job. +// +// Execution order across all registered features: +// +// 1. Object metadata edits +// 2. Job spec edits +// 3. Pod template metadata edits +// 4. Pod spec edits +// 5. Regular container presence operations +// 6. Regular container edits +// 7. Init container presence operations +// 8. Init container edits +// +// Features are applied in the order they were registered. Within each category +// of a single feature, edits are applied in their registration order. +// +// Container selectors are evaluated against a snapshot taken after the current +// feature's container presence operations have been applied. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.jobMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.jobMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Job spec + if len(plan.jobSpecEdits) > 0 { + editor := editors.NewJobSpecEditor(&m.current.Spec) + for _, edit := range plan.jobSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. Pod template metadata + if len(plan.podTemplateMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.Spec.Template.ObjectMeta) + for _, edit := range plan.podTemplateMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 4. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec.Template.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 5. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.Template.Spec.Containers, op) + } + + // 6. Regular container edits + if len(plan.containerEdits) > 0 { + // Take snapshot of containers AFTER presence ops but BEFORE applying any edits + snapshots := make([]corev1.Container, len(m.current.Spec.Template.Spec.Containers)) + for i := range m.current.Spec.Template.Spec.Containers { + m.current.Spec.Template.Spec.Containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Template.Spec.Containers { + container := &m.current.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. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.Template.Spec.InitContainers, op) + } + + // 8. Init container edits + if len(plan.initContainerEdits) > 0 { + // Take snapshot of init containers AFTER presence ops but BEFORE applying any edits + snapshots := make([]corev1.Container, len(m.current.Spec.Template.Spec.InitContainers)) + for i := range m.current.Spec.Template.Spec.InitContainers { + m.current.Spec.Template.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Template.Spec.InitContainers { + container := &m.current.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) + } +} From f5b042b0b55f95707ef568676efe075db597ff93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:47 +0000 Subject: [PATCH 04/19] Add job-primitive example with mutations, flavors, and status handlers Demonstrates building a Job primitive with version-gated image updates, tracing env vars, retry policy configuration, field preservation flavors, custom completion status handler, and data extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/job-primitive/README.md | 35 ++++++ examples/job-primitive/app/controller.go | 54 +++++++++ examples/job-primitive/app/owner.go | 20 ++++ examples/job-primitive/features/flavors.go | 16 +++ examples/job-primitive/features/mutations.go | 69 +++++++++++ examples/job-primitive/features/status.go | 28 +++++ examples/job-primitive/main.go | 118 +++++++++++++++++++ examples/job-primitive/resources/job.go | 80 +++++++++++++ 8 files changed, 420 insertions(+) create mode 100644 examples/job-primitive/README.md create mode 100644 examples/job-primitive/app/controller.go create mode 100644 examples/job-primitive/app/owner.go create mode 100644 examples/job-primitive/features/flavors.go create mode 100644 examples/job-primitive/features/mutations.go create mode 100644 examples/job-primitive/features/status.go create mode 100644 examples/job-primitive/main.go create mode 100644 examples/job-primitive/resources/job.go diff --git a/examples/job-primitive/README.md b/examples/job-primitive/README.md new file mode 100644 index 00000000..cba1cf8c --- /dev/null +++ b/examples/job-primitive/README.md @@ -0,0 +1,35 @@ +# Job Primitive Example + +This example demonstrates the usage of the `job` primitive within the operator component framework. +It shows how to manage a Kubernetes Job as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a Job with basic metadata, spec, and restart policy. +- **Feature Mutations**: Applying version-gated or conditional changes (env vars, image version, retry policies) 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 completion status (`ConvergeStatus`). +- **Suspension**: Demonstrating how Jobs are suspended (deleted by default) when the component is suspended. +- **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, retry policies, and version-based image updates. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. + - `status.go`: implementation of a custom handler for completion status. +- `resources/`: Contains the central `NewJobResource` factory that assembles all features using the `job.Builder`. +- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/job-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 after each reconciliation step. diff --git a/examples/job-primitive/app/controller.go b/examples/job-primitive/app/controller.go new file mode 100644 index 00000000..52262e7c --- /dev/null +++ b/examples/job-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the job primitive. +package app + +import ( + "context" + + "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" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewJobResource is a factory function to create the job resource. + // This allows us to inject the resource construction logic. + NewJobResource 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 job resource for this owner. + jobResource, err := r.NewJobResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the job. + comp, err := component.NewComponentBuilder(). + WithName("example-migration"). + WithConditionType("MigrationReady"). + WithResource(jobResource, 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/job-primitive/app/owner.go b/examples/job-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/job-primitive/app/owner.go @@ -0,0 +1,20 @@ +package app + +import ( + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme diff --git a/examples/job-primitive/features/flavors.go b/examples/job-primitive/features/flavors.go new file mode 100644 index 00000000..337ff4ca --- /dev/null +++ b/examples/job-primitive/features/flavors.go @@ -0,0 +1,16 @@ +// Package features provides sample features for the job primitive. +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" +) + +// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. +func PreserveLabelsFlavor() job.FieldApplicationFlavor { + return job.PreserveCurrentLabels +} + +// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. +func PreserveAnnotationsFlavor() job.FieldApplicationFlavor { + return job.PreserveCurrentAnnotations +} diff --git a/examples/job-primitive/features/mutations.go b/examples/job-primitive/features/mutations.go new file mode 100644 index 00000000..0f4fd40a --- /dev/null +++ b/examples/job-primitive/features/mutations.go @@ -0,0 +1,69 @@ +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/job" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds tracing environment variables to the job containers. +func TracingFeature(enabled bool) job.Mutation { + return job.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *job.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "OTEL_EXPORTER_OTLP_ENDPOINT", + Value: "http://otel-collector:4317", + }) + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "OTEL_TRACES_SAMPLER", + Value: "always_on", + }) + + return nil + }, + } +} + +// RetryPolicyFeature configures the job's retry behavior. +func RetryPolicyFeature(version string) job.Mutation { + return job.Mutation{ + Name: "RetryPolicy", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *job.Mutator) error { + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetActiveDeadlineSeconds(600) + return nil + }) + + return nil + }, + } +} + +// VersionFeature sets the image version and a label. +func VersionFeature(version string) job.Mutation { + return job.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *job.Mutator) error { + m.EditContainers(selectors.ContainerNamed("migrate"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-app-migration:%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/job-primitive/features/status.go b/examples/job-primitive/features/status.go new file mode 100644 index 00000000..9cd2b04d --- /dev/null +++ b/examples/job-primitive/features/status.go @@ -0,0 +1,28 @@ +package features + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" + batchv1 "k8s.io/api/batch/v1" +) + +// CustomConvergeStatus demonstrates a custom handler for job completion status. +func CustomConvergeStatus() func(concepts.ConvergingOperation, *batchv1.Job) (concepts.CompletionStatusWithReason, error) { + return func(op concepts.ConvergingOperation, j *batchv1.Job) (concepts.CompletionStatusWithReason, error) { + // Use the default logic but add a custom reason or additional checks. + status, err := job.DefaultConvergingStatusHandler(op, j) + if err != nil { + return status, err + } + + if status.Status == concepts.CompletionStatusCompleted { + status.Reason = "Migration completed successfully" + } else if status.Status == concepts.CompletionStatusRunning { + status.Reason = fmt.Sprintf("Migration in progress: %s", status.Reason) + } + + return status, nil + } +} diff --git a/examples/job-primitive/main.go b/examples/job-primitive/main.go new file mode 100644 index 00000000..7d53ef9b --- /dev/null +++ b/examples/job-primitive/main.go @@ -0,0 +1,118 @@ +// Package main is the entry point for the job 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/job-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/job-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: false, + 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 job resource factory. + NewJobResource: resources.NewJobResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + Suspended: true, // Suspend the job + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, 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/job-primitive/resources/job.go b/examples/job-primitive/resources/job.go new file mode 100644 index 00000000..631be88d --- /dev/null +++ b/examples/job-primitive/resources/job.go @@ -0,0 +1,80 @@ +// Package resources provides resource implementations for the job primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/job-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/job-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewJobResource constructs a job primitive resource with all the features. +func NewJobResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base Job object. + base := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-migration", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + { + Name: "migrate", + Image: "my-app-migration:latest", // Will be overwritten by VersionFeature + }, + }, + }, + }, + }, + } + + // 2. Initialize the job builder. + builder := job.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.RetryPolicyFeature(owner.Spec.Version)) + + // 4. Configure flavors (e.g., preserve labels/annotations if they were modified externally). + builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) + builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) + + // 5. Configure custom status handler. + builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) + + // 6. Data extraction (optional). + builder.WithDataExtractor(func(j batchv1.Job) error { + fmt.Printf("Reconciling job: %s, active: %d, succeeded: %d, failed: %d\n", + j.Name, j.Status.Active, j.Status.Succeeded, j.Status.Failed) + + // Print the complete job resource object as yaml + y, err := yaml.Marshal(j) + if err != nil { + return fmt.Errorf("failed to marshal job to yaml: %w", err) + } + fmt.Printf("Complete Job Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 7. Build the final resource. + return builder.Build() +} From d59c2769292b2d7a25b67241e6a0d8c7b12126bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:43:50 +0000 Subject: [PATCH 05/19] Add Job primitive documentation Covers capabilities, building, mutations, internal ordering, editors, convenience methods, suspension behavior, flavors, and guidance. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/job.md | 257 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/primitives/job.md diff --git a/docs/primitives/job.md b/docs/primitives/job.md new file mode 100644 index 00000000..92ea7425 --- /dev/null +++ b/docs/primitives/job.md @@ -0,0 +1,257 @@ +# Job Primitive + +The `job` primitive is the framework's built-in task abstraction for managing Kubernetes `Job` resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata — following the same pod-template mutation pattern as the Deployment primitive. + +## Capabilities + +| Capability | Detail | +|-------------------------|---------------------------------------------------------------------------------------------------| +| **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` | +| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | + +## Building a Job Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" + +base := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db-migration", + Namespace: owner.Namespace, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + {Name: "migrate", Image: "my-app-migration:latest"}, + }, + }, + }, + }, +} + +resource, err := job.NewBuilder(base). + WithFieldApplicationFlavor(job.PreserveCurrentLabels). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Job` 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: + +```go +func MyFeatureMutation(version string) job.Mutation { + return job.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *job.Mutator) error { + // record edits here + return nil + }, + } +} +``` + +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 + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TracingMutation(version string, enabled bool) job.Mutation { + return job.Mutation{ + Name: "tracing", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *job.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "OTEL_EXPORTER_OTLP_ENDPOINT", + Value: "http://otel-collector:4317", + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +Pass a `[]feature.VersionConstraint` to gate on a semver range: + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyMigrationMutation(version string) job.Mutation { + return job.Mutation{ + Name: "legacy-migration-format", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *job.Mutator) error { + m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "MIGRATION_FORMAT", Value: "v1"}) + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## 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. + +| Step | Category | What it affects | +|---|---|---| +| 1 | Job metadata edits | Labels and annotations on the `Job` object | +| 2 | JobSpec edits | Completions, parallelism, backoff limit, deadline, 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.template.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.template.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. + +## Editors + +### JobSpecEditor + +Controls job-level settings via `m.EditJobSpec`. + +Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, `SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetActiveDeadlineSeconds(600) + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.Raw().Suspend = ptr.To(true) + 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("migration-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "migration-config"}, + }, + }, + }) + 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("migrate"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "postgres:5432"}) + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Job` 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` exposes convenience wrappers that target all containers at once: + +| Method | Equivalent to | +|-------------------------------|---------------------------------------------------------------| +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | + +## Suspension + +Jobs use the Task lifecycle for suspension, which differs from Workloads: + +- **Default behavior**: `DefaultDeleteOnSuspendHandler` returns `true`, meaning the Job is deleted from the cluster during suspension. +- **Suspend mutation**: `DefaultSuspendMutationHandler` sets `spec.suspend=true`, which prevents the Job controller from creating new pods while allowing existing pods to complete. +- **Suspension status**: `DefaultSuspensionStatusHandler` checks if `spec.suspend=true` and `status.active=0`. + +Override any of these via the Builder: + +```go +resource, err := job.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(j *batchv1.Job) bool { + return false // Keep the Job in the cluster when suspended + }). + Build() +``` + +## Flavors + +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. + +### PreserveCurrentLabels + +Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. + +### PreserveCurrentAnnotations + +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. + +### PreserveCurrentPodTemplateLabels + +Preserves labels on the live object's pod template that are absent from the applied desired state. + +### PreserveCurrentPodTemplateAnnotations + +Preserves annotations on the live object's pod template that are absent from the applied desired state. + +## 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. + +**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. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if init containers or sidecar containers are present. + +**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension. Override `WithCustomSuspendDeletionDecision` if you need to keep the Job resource in the cluster. From 2d257fd40f2af5947bc166e189841bee9c0b3310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:55:33 +0000 Subject: [PATCH 06/19] Fix staticcheck lint: use tagged switch in status handler Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/job-primitive/features/status.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/job-primitive/features/status.go b/examples/job-primitive/features/status.go index 9cd2b04d..ebdf6fc8 100644 --- a/examples/job-primitive/features/status.go +++ b/examples/job-primitive/features/status.go @@ -17,9 +17,10 @@ func CustomConvergeStatus() func(concepts.ConvergingOperation, *batchv1.Job) (co return status, err } - if status.Status == concepts.CompletionStatusCompleted { + switch status.Status { + case concepts.CompletionStatusCompleted: status.Reason = "Migration completed successfully" - } else if status.Status == concepts.CompletionStatusRunning { + case concepts.CompletionStatusRunning: status.Reason = fmt.Sprintf("Migration in progress: %s", status.Reason) } From 0fdd8114ff47d878d80c69da2279504342d66b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:32:14 +0000 Subject: [PATCH 07/19] Address Copilot review comments on Job primitive - Use corev1.ConditionTrue instead of string literal "True" in status condition checks (handlers.go) - Fix range variable address issue in findEnv test helper by iterating by index (mutator_test.go) - Fix doc comments to reference concepts.X instead of component.X for interface types (resource.go) Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/handlers.go | 5 +++-- pkg/primitives/job/mutator_test.go | 6 +++--- pkg/primitives/job/resource.go | 8 ++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/primitives/job/handlers.go b/pkg/primitives/job/handlers.go index 6d05b92b..5c58e3af 100644 --- a/pkg/primitives/job/handlers.go +++ b/pkg/primitives/job/handlers.go @@ -6,6 +6,7 @@ import ( "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" ) // DefaultConvergingStatusHandler is the default logic for determining if a Job has completed or is still running. @@ -20,13 +21,13 @@ func DefaultConvergingStatusHandler( op concepts.ConvergingOperation, job *batchv1.Job, ) (concepts.CompletionStatusWithReason, error) { for _, cond := range job.Status.Conditions { - if cond.Type == batchv1.JobComplete && cond.Status == "True" { + if cond.Type == batchv1.JobComplete && cond.Status == corev1.ConditionTrue { return concepts.CompletionStatusWithReason{ Status: concepts.CompletionStatusCompleted, Reason: "Job completed successfully", }, nil } - if cond.Type == batchv1.JobFailed && cond.Status == "True" { + if cond.Type == batchv1.JobFailed && cond.Status == corev1.ConditionTrue { reason := "Job failed" if cond.Message != "" { reason = fmt.Sprintf("Job failed: %s", cond.Message) diff --git a/pkg/primitives/job/mutator_test.go b/pkg/primitives/job/mutator_test.go index 7e9d4d59..3c9d5c6c 100644 --- a/pkg/primitives/job/mutator_test.go +++ b/pkg/primitives/job/mutator_test.go @@ -52,9 +52,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 diff --git a/pkg/primitives/job/resource.go b/pkg/primitives/job/resource.go index 7c0a4531..17dcbb5a 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -17,10 +17,10 @@ func DefaultFieldApplicator(current, desired *batchv1.Job) error { // reconciliation loop. // // It implements several component interfaces to integrate with the operator-component-framework: -// - component.Resource: for basic identity and mutation behavior. -// - component.Completable: for run-to-completion tracking. -// - component.Suspendable: for controlled deactivation (suspend or delete). -// - component.DataExtractable: for exporting information after successful reconciliation. +// - concepts.Resource: for basic identity and mutation behavior. +// - concepts.Completable: for run-to-completion tracking. +// - concepts.Suspendable: for controlled deactivation (suspend or delete). +// - concepts.DataExtractable: for exporting information after successful reconciliation. // // This resource handles the lifecycle of a Job, including initial creation, // updates via feature mutations, and completion status monitoring. From 1534d482949ee47e1a657b8c33a90ce5bf7aea1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:28:40 +0000 Subject: [PATCH 08/19] Address Copilot review: failure reason fallback, doc comments, resource tests - Use cond.Reason as fallback when cond.Message is empty in Job failure status handler, since Kubernetes often provides details in Reason only - Fix doc comments to reference component.Resource instead of concepts.Resource, matching conventions used by other primitives - Add resource_test.go with comprehensive coverage for Resource-level behaviors including Suspend/DeleteOnSuspend wiring, SuspensionStatus and ConvergingStatus delegation, ExtractData, and custom field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/handlers.go | 5 +- pkg/primitives/job/handlers_test.go | 15 +- pkg/primitives/job/resource.go | 8 +- pkg/primitives/job/resource_test.go | 361 ++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 pkg/primitives/job/resource_test.go diff --git a/pkg/primitives/job/handlers.go b/pkg/primitives/job/handlers.go index 5c58e3af..032216c2 100644 --- a/pkg/primitives/job/handlers.go +++ b/pkg/primitives/job/handlers.go @@ -29,8 +29,11 @@ func DefaultConvergingStatusHandler( } if cond.Type == batchv1.JobFailed && cond.Status == corev1.ConditionTrue { reason := "Job failed" - if cond.Message != "" { + switch { + case cond.Message != "": reason = fmt.Sprintf("Job failed: %s", cond.Message) + case cond.Reason != "": + reason = fmt.Sprintf("Job failed: %s", cond.Reason) } return concepts.CompletionStatusWithReason{ Status: concepts.CompletionStatusFailing, diff --git a/pkg/primitives/job/handlers_test.go b/pkg/primitives/job/handlers_test.go index d04c0d60..ac204bd5 100644 --- a/pkg/primitives/job/handlers_test.go +++ b/pkg/primitives/job/handlers_test.go @@ -45,7 +45,20 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { wantReason: "Job failed: BackoffLimitExceeded", }, { - name: "failed without message", + name: "failed with reason only", + op: concepts.ConvergingOperationUpdated, + job: &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}, + }, + }, + }, + wantStatus: concepts.CompletionStatusFailing, + wantReason: "Job failed: BackoffLimitExceeded", + }, + { + name: "failed without message or reason", op: concepts.ConvergingOperationUpdated, job: &batchv1.Job{ Status: batchv1.JobStatus{ diff --git a/pkg/primitives/job/resource.go b/pkg/primitives/job/resource.go index 17dcbb5a..7c0a4531 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -17,10 +17,10 @@ func DefaultFieldApplicator(current, desired *batchv1.Job) error { // reconciliation loop. // // It implements several component interfaces to integrate with the operator-component-framework: -// - concepts.Resource: for basic identity and mutation behavior. -// - concepts.Completable: for run-to-completion tracking. -// - concepts.Suspendable: for controlled deactivation (suspend or delete). -// - concepts.DataExtractable: for exporting information after successful reconciliation. +// - component.Resource: for basic identity and mutation behavior. +// - component.Completable: for run-to-completion tracking. +// - component.Suspendable: for controlled deactivation (suspend or delete). +// - component.DataExtractable: for exporting information after successful reconciliation. // // This resource handles the lifecycle of a Job, including initial creation, // updates via feature mutations, and completion status monitoring. diff --git a/pkg/primitives/job/resource_test.go b/pkg/primitives/job/resource_test.go new file mode 100644 index 00000000..a8315dea --- /dev/null +++ b/pkg/primitives/job/resource_test.go @@ -0,0 +1,361 @@ +package job + +import ( + "errors" + "testing" + + "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/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 newValidJob() *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "worker", Image: "busybox"}, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidJob()).Build() + require.NoError(t, err) + assert.Equal(t, "batch/v1/Job/test-ns/test-job", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + job := newValidJob() + res, err := NewBuilder(job).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*batchv1.Job) + require.True(t, ok) + assert.Equal(t, job.Name, got.Name) + assert.Equal(t, job.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-job", job.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidJob() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + current := &batchv1.Job{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "busybox", current.Spec.Template.Spec.Containers[0].Image) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidJob() + 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.Job{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "BAR", current.Spec.Template.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidJob() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.Raw().BackoffLimit = int32Ptr(5) + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + if e.Raw().BackoffLimit != nil && *e.Raw().BackoffLimit == 5 { + e.Raw().BackoffLimit = int32Ptr(10) + } + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &batchv1.Job{} + require.NoError(t, res.Mutate(current)) + + require.NotNil(t, current.Spec.BackoffLimit) + assert.Equal(t, int32(10), *current.Spec.BackoffLimit) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, j *batchv1.Job) (concepts.CompletionStatusWithReason, error) { + args := m.Called(op, j) + return args.Get(0).(concepts.CompletionStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(j *batchv1.Job) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(j) + 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(j *batchv1.Job) bool { + args := m.Called(j) + return args.Bool(0) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + job := newValidJob() + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", job).Return(false) + + res, err := NewBuilder(job). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(job).Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + job := newValidJob() + + t.Run("Suspend registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(job).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := job.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + require.NotNil(t, current.Spec.Suspend) + assert.True(t, *current.Spec.Suspend) + }) + + t.Run("Suspend 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.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.Raw().BackoffLimit = int32Ptr(0) + return nil + }) + }) + + res, err := NewBuilder(job). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := job.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + m.AssertExpectations(t) + require.NotNil(t, current.Spec.BackoffLimit) + assert.Equal(t, int32(0), *current.Spec.BackoffLimit) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job", Namespace: "test-ns"}, + Spec: batchv1.JobSpec{ + Suspend: boolPtr(true), + }, + Status: batchv1.JobStatus{ + Active: 0, + }, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", job).Return(statusSuspended, nil) + + res, err := NewBuilder(job). + 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", func(t *testing.T) { + res, err := NewBuilder(job).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) +} + +func TestResource_ConvergingStatus(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job", Namespace: "test-ns"}, + Status: batchv1.JobStatus{ + Active: 1, + }, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusRunning := concepts.CompletionStatusWithReason{Status: concepts.CompletionStatusRunning} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, job).Return(statusRunning, nil) + + res, err := NewBuilder(job). + WithCustomConvergeStatus(m.ConvergingStatus). + Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.CompletionStatusRunning, status.Status) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(job).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.CompletionStatusRunning, status.Status) + }) +} + +func TestResource_ExtractData(t *testing.T) { + job := newValidJob() + + extractedImage := "" + res, err := NewBuilder(job). + WithDataExtractor(func(j batchv1.Job) error { + extractedImage = j.Spec.Template.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.NoError(t, err) + assert.Equal(t, "busybox", extractedImage) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidJob()). + WithDataExtractor(func(_ batchv1.Job) 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") +} + +func TestResource_CustomFieldApplicator(t *testing.T) { + desired := newValidJob() + desired.Labels = map[string]string{"app": "test"} + + applicatorCalled := false + res, err := NewBuilder(desired). + WithCustomFieldApplicator(func(current *batchv1.Job, desired *batchv1.Job) error { + applicatorCalled = true + current.Name = desired.Name + current.Namespace = desired.Namespace + current.Spec = *desired.Spec.DeepCopy() + return nil + }). + Build() + require.NoError(t, err) + + current := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"external": "label"}, + }, + } + err = res.Mutate(current) + require.NoError(t, err) + + assert.True(t, applicatorCalled) + assert.Equal(t, "busybox", current.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "label", current.Labels["external"], "External label should be preserved") + assert.NotContains(t, current.Labels, "app", "Desired label should NOT be applied by custom applicator") + + t.Run("returns error", func(t *testing.T) { + res, err := NewBuilder(desired). + WithCustomFieldApplicator(func(_ *batchv1.Job, _ *batchv1.Job) error { + return errors.New("applicator error") + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&batchv1.Job{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") + }) +} + +func int32Ptr(i int32) *int32 { + return &i +} From a46382a1ecf296f7c911cd0abf988fb9e1e4f1c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:51:03 +0000 Subject: [PATCH 09/19] Preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/resource.go | 7 ++++- pkg/primitives/job/resource_test.go | 44 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/job/resource.go b/pkg/primitives/job/resource.go index 7c0a4531..1debfa9c 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -7,9 +7,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.Job) error { + original := current.DeepCopy() *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/job/resource_test.go b/pkg/primitives/job/resource_test.go index a8315dea..1f55dafe 100644 --- a/pkg/primitives/job/resource_test.go +++ b/pkg/primitives/job/resource_test.go @@ -313,6 +313,50 @@ func TestResource_ExtractData_Error(t *testing.T) { assert.Contains(t, err.Error(), "extract error") } +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + 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.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{"app": "test"}, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: int32Ptr(5), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + require.NotNil(t, current.Spec.BackoffLimit) + assert.Equal(t, int32(5), *current.Spec.BackoffLimit) + 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 TestResource_CustomFieldApplicator(t *testing.T) { desired := newValidJob() desired.Labels = map[string]string{"app": "test"} From 1522f249d8e29d88f88fc5d32e324ea5fb6c006d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 02:56:51 +0000 Subject: [PATCH 10/19] Add cross-mutation selector snapshot test for Job primitive Validates that a container added by one mutation can be selected and configured by a subsequent mutation, exercising the feature-boundary semantics of the mutator's plan-and-apply pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/resource_test.go | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pkg/primitives/job/resource_test.go b/pkg/primitives/job/resource_test.go index 1f55dafe..10ce1720 100644 --- a/pkg/primitives/job/resource_test.go +++ b/pkg/primitives/job/resource_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -126,6 +127,51 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { assert.Equal(t, int32(10), *current.Spec.BackoffLimit) } +func TestResource_Mutate_CrossMutationSelectorSnapshot(t *testing.T) { + desired := newValidJob() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-sidecar", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "sidecar", + Image: "sidecar:latest", + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "configure-sidecar", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("sidecar"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &batchv1.Job{} + require.NoError(t, res.Mutate(current)) + + // The sidecar container should exist and have the env var from the second mutation. + var sidecar *corev1.Container + for i := range current.Spec.Template.Spec.Containers { + if current.Spec.Template.Spec.Containers[i].Name == "sidecar" { + sidecar = ¤t.Spec.Template.Spec.Containers[i] + break + } + } + require.NotNil(t, sidecar, "sidecar container should be present") + require.Len(t, sidecar.Env, 1) + assert.Equal(t, "LOG_LEVEL", sidecar.Env[0].Name) + assert.Equal(t, "debug", sidecar.Env[0].Value) +} + type mockHandlers struct { mock.Mock } From 4733a386565dba6f9fbe1715f0327e828b41a346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:12 +0000 Subject: [PATCH 11/19] Fix job mutator constructor to not call beginFeature Match the pattern used by deployment/configmap primitives: initialize with an empty featurePlan directly instead of calling beginFeature(), which would create a duplicate empty feature when the generic mutate_helper also calls beginFeature(). Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/mutator.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/job/mutator.go b/pkg/primitives/job/mutator.go index ded670c8..10db84d2 100644 --- a/pkg/primitives/job/mutator.go +++ b/pkg/primitives/job/mutator.go @@ -52,8 +52,11 @@ type Mutator struct { // NewMutator creates a new Mutator for the given Job. func NewMutator(current *batchv1.Job) *Mutator { - m := &Mutator{current: current} - m.beginFeature() + m := &Mutator{ + current: current, + plans: []featurePlan{{}}, + } + m.active = &m.plans[0] return m } From 8f166b9e0be35081cc2256bcff0b241b56fe92a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:33:47 +0000 Subject: [PATCH 12/19] Export BeginFeature and preserve status in DefaultFieldApplicator Align with upstream architectural fixes: export beginFeature() to BeginFeature() to satisfy the FeatureMutator interface, and add status preservation to DefaultFieldApplicator so spec-level reconciliation does not clear server-written status data. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/job.md | 6 +++++ pkg/primitives/job/mutator.go | 4 ++-- pkg/primitives/job/mutator_test.go | 8 +++---- pkg/primitives/job/resource.go | 7 +++--- pkg/primitives/job/resource_test.go | 35 +++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/primitives/job.md b/docs/primitives/job.md index 92ea7425..05d083cf 100644 --- a/docs/primitives/job.md +++ b/docs/primitives/job.md @@ -11,6 +11,12 @@ The `job` primitive is the framework's built-in task abstraction for managing Ku | **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers | | **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +## Default Field Application + +`DefaultFieldApplicator` replaces the current Job 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 Job Primitive ```go diff --git a/pkg/primitives/job/mutator.go b/pkg/primitives/job/mutator.go index 10db84d2..dbbc07fc 100644 --- a/pkg/primitives/job/mutator.go +++ b/pkg/primitives/job/mutator.go @@ -60,9 +60,9 @@ func NewMutator(current *batchv1.Job) *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/job/mutator_test.go b/pkg/primitives/job/mutator_test.go index 3c9d5c6c..a21fc1be 100644 --- a/pkg/primitives/job/mutator_test.go +++ b/pkg/primitives/job/mutator_test.go @@ -339,7 +339,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(job) // Feature A: sets backoff to 2, image to v2 - m.beginFeature() + m.BeginFeature() m.EditJobSpec(func(e *editors.JobSpecEditor) error { e.SetBackoffLimit(2) return nil @@ -350,7 +350,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B: sets backoff to 3, image to v3 - m.beginFeature() + m.BeginFeature() m.EditJobSpec(func(e *editors.JobSpecEditor) error { e.SetBackoffLimit(3) return nil @@ -382,14 +382,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(job) // 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/job/resource.go b/pkg/primitives/job/resource.go index 1debfa9c..1c95d2cc 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -8,13 +8,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.Job) error { original := current.DeepCopy() *current = *desired.DeepCopy() generic.PreserveServerManagedFields(current, original) + generic.PreserveStatus(current, original) return nil } diff --git a/pkg/primitives/job/resource_test.go b/pkg/primitives/job/resource_test.go index 10ce1720..f860900c 100644 --- a/pkg/primitives/job/resource_test.go +++ b/pkg/primitives/job/resource_test.go @@ -403,6 +403,41 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } +func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { + current := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + Status: batchv1.JobStatus{ + Active: 2, + Succeeded: 1, + Failed: 0, + }, + } + desired := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + }, + Spec: batchv1.JobSpec{ + BackoffLimit: int32Ptr(5), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec is applied + require.NotNil(t, current.Spec.BackoffLimit) + assert.Equal(t, int32(5), *current.Spec.BackoffLimit) + + // Status from the live object is preserved + assert.Equal(t, int32(2), current.Status.Active) + assert.Equal(t, int32(1), current.Status.Succeeded) + assert.Equal(t, int32(0), current.Status.Failed) +} + func TestResource_CustomFieldApplicator(t *testing.T) { desired := newValidJob() desired.Labels = map[string]string{"app": "test"} From 12070ac835bf0dd398bd9eae1df023fb8e090997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:06 +0000 Subject: [PATCH 13/19] Format markdown files with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/job.md | 102 +++++++++++++++++++------------ examples/job-primitive/README.md | 17 +++--- 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/docs/primitives/job.md b/docs/primitives/job.md index 05d083cf..e914b4e7 100644 --- a/docs/primitives/job.md +++ b/docs/primitives/job.md @@ -1,19 +1,24 @@ # Job Primitive -The `job` primitive is the framework's built-in task abstraction for managing Kubernetes `Job` resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata — following the same pod-template mutation pattern as the Deployment primitive. +The `job` primitive is the framework's built-in task abstraction for managing Kubernetes `Job` resources. It integrates +fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata — +following the same pod-template mutation pattern as the Deployment primitive. ## Capabilities -| Capability | Detail | -|-------------------------|---------------------------------------------------------------------------------------------------| -| **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` | -| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +| Capability | Detail | +| ----------------------- | ----------------------------------------------------------------------------------------------- | +| **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` | +| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Default Field Application -`DefaultFieldApplicator` replaces the current Job 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 Job 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. @@ -47,9 +52,11 @@ resource, err := job.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Job` 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 `Job` 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) job.Mutation { @@ -64,7 +71,8 @@ func MyFeatureMutation(version string) job.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 @@ -115,20 +123,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 | Job metadata edits | Labels and annotations on the `Job` object | -| 2 | JobSpec edits | Completions, parallelism, backoff limit, deadline, 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.template.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.template.spec.initContainers` | -| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Job metadata edits | Labels and annotations on the `Job` object | +| 2 | JobSpec edits | Completions, parallelism, backoff limit, deadline, 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.template.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.template.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 @@ -136,7 +146,8 @@ Container edits (steps 6 and 8) are evaluated against a snapshot taken *after* p Controls job-level settings 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 { @@ -159,7 +170,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 { @@ -178,9 +191,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("migrate"), func(e *editors.ContainerEditor) error { @@ -192,7 +207,8 @@ m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerE ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Job` object itself, or `m.EditPodTemplateMetadata` to target the pod template. +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Job` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. @@ -208,7 +224,7 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { The `Mutator` exposes convenience wrappers that target all containers at once: | Method | Equivalent to | -|-------------------------------|---------------------------------------------------------------| +| ----------------------------- | ------------------------------------------------------------- | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | | `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | @@ -216,8 +232,10 @@ The `Mutator` exposes convenience wrappers that target all containers at once: Jobs use the Task lifecycle for suspension, which differs from Workloads: -- **Default behavior**: `DefaultDeleteOnSuspendHandler` returns `true`, meaning the Job is deleted from the cluster during suspension. -- **Suspend mutation**: `DefaultSuspendMutationHandler` sets `spec.suspend=true`, which prevents the Job controller from creating new pods while allowing existing pods to complete. +- **Default behavior**: `DefaultDeleteOnSuspendHandler` returns `true`, meaning the Job is deleted from the cluster + during suspension. +- **Suspend mutation**: `DefaultSuspendMutationHandler` sets `spec.suspend=true`, which prevents the Job controller from + creating new pods while allowing existing pods to complete. - **Suspension status**: `DefaultSuspensionStatusHandler` checks if `spec.suspend=true` and `status.active=0`. Override any of these via the Builder: @@ -232,7 +250,8 @@ resource, err := job.NewBuilder(base). ## 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 @@ -240,7 +259,8 @@ Preserves labels present on the live object but absent from the applied desired ### 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. ### PreserveCurrentPodTemplateLabels @@ -252,12 +272,18 @@ Preserves annotations on the live object's pod template that are absent from the ## 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 init containers or sidecar containers are present. +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if init containers or sidecar containers are present. -**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension. Override `WithCustomSuspendDeletionDecision` if you need to keep the Job resource in the cluster. +**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension. +Override `WithCustomSuspendDeletionDecision` if you need to keep the Job resource in the cluster. diff --git a/examples/job-primitive/README.md b/examples/job-primitive/README.md index cba1cf8c..3f06a822 100644 --- a/examples/job-primitive/README.md +++ b/examples/job-primitive/README.md @@ -1,11 +1,13 @@ # Job Primitive Example -This example demonstrates the usage of the `job` primitive within the operator component framework. -It shows how to manage a Kubernetes Job as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `job` primitive within the operator component framework. It shows how to +manage a Kubernetes Job as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a Job with basic metadata, spec, and restart policy. -- **Feature Mutations**: Applying version-gated or conditional changes (env vars, image version, retry policies) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). +- **Feature Mutations**: Applying version-gated or conditional changes (env vars, image version, retry policies) 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 completion status (`ConvergeStatus`). - **Suspension**: Demonstrating how Jobs are suspended (deleted by default) when the component is suspended. - **Data Extraction**: Harvesting information from the reconciled resource. @@ -14,9 +16,9 @@ It shows how to manage a Kubernetes Job as a component of a larger application, - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: tracing env vars, retry policies, and version-based image updates. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. - - `status.go`: implementation of a custom handler for completion status. + - `mutations.go`: tracing env vars, retry policies, and version-based image updates. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. + - `status.go`: implementation of a custom handler for completion status. - `resources/`: Contains the central `NewJobResource` factory that assembles all features using the `job.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. @@ -29,6 +31,7 @@ go run examples/job-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 15df44adc496160cb0d72b99cdb19cc1b0e52d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:33:28 +0000 Subject: [PATCH 14/19] Address PR review: check Build() errors in tests and update Mutate() doc - Assert Build() errors with require.NoError/require.NotNil in all three flavors_test.go subtests to surface root causes on failure - Add missing field-application flavor step to Mutate() doc comment Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/flavors_test.go | 18 ++++++++++++------ pkg/primitives/job/resource.go | 7 +++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pkg/primitives/job/flavors_test.go b/pkg/primitives/job/flavors_test.go index edfb1123..3313cdcf 100644 --- a/pkg/primitives/job/flavors_test.go +++ b/pkg/primitives/job/flavors_test.go @@ -29,11 +29,13 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(PreserveCurrentLabels). Build() + require.NoError(t, err) + require.NotNil(t, res) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, "desired", current.Labels["app"]) @@ -58,12 +60,14 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return nil } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor1). WithFieldApplicationFlavor(flavor2). Build() + require.NoError(t, err) + require.NotNil(t, res) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, []string{"flavor1", "flavor2"}, order) }) @@ -75,11 +79,13 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return flavorErr } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor). Build() + require.NoError(t, err) + require.NotNil(t, res) - 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)) diff --git a/pkg/primitives/job/resource.go b/pkg/primitives/job/resource.go index 1c95d2cc..8842d22e 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -61,9 +61,12 @@ func (r *Resource) Object() (client.Object, error) { // 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, +// 2. Field-Application Flavors: Any configured field-application flavors are +// applied on top of the baseline field application to adjust how fields are +// merged or preserved. +// 3. Feature Mutations: All registered feature-based mutations are applied, // allowing for granular, version-gated changes to the Job. -// 3. Suspension: If the resource is in a suspending state, the suspension +// 4. Suspension: If the resource is in a suspending state, the suspension // logic (e.g., setting suspend=true) is applied. // // This method is invoked by the framework during the "Update" phase of From 27a4dec660d495336b38cc75423493604a13fc3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 01:12:20 +0000 Subject: [PATCH 15/19] Address Copilot review: add job to primitives index and fix API name in example README Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 1 + examples/job-primitive/README.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/primitives.md b/docs/primitives.md index dc2d4c2a..4ec8c56c 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -155,6 +155,7 @@ have been applied. This means a single mutation can safely add a container and t | --------------------------- | -------- | ----------------------------------------- | | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/job` | Task | [job.md](primitives/job.md) | ## Usage Examples diff --git a/examples/job-primitive/README.md b/examples/job-primitive/README.md index 3f06a822..0eec4ed4 100644 --- a/examples/job-primitive/README.md +++ b/examples/job-primitive/README.md @@ -8,7 +8,8 @@ manage a Kubernetes Job as a component of a larger application, utilizing featur 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 completion status (`ConvergeStatus`). +- **Custom Status Handlers**: Overriding the default `ConvergingStatus` interface using the `WithCustomConvergeStatus` + builder option. - **Suspension**: Demonstrating how Jobs are suspended (deleted by default) when the component is suspended. - **Data Extraction**: Harvesting information from the reconciled resource. From ebb22c4cda6b9a0f388c91cc0373f3ff51dd78b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:57:37 +0000 Subject: [PATCH 16/19] Align job mutator construction with deployment/configmap primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not initialize a feature plan in NewMutator — require callers to call BeginFeature() before registering mutations, matching the pattern established in the deployment and configmap primitives. Update all tests to call BeginFeature() and add constructor/plan-invariant tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/handlers_test.go | 1 + pkg/primitives/job/mutator.go | 9 +- pkg/primitives/job/mutator_test.go | 207 +++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 7 deletions(-) diff --git a/pkg/primitives/job/handlers_test.go b/pkg/primitives/job/handlers_test.go index ac204bd5..f329ee93 100644 --- a/pkg/primitives/job/handlers_test.go +++ b/pkg/primitives/job/handlers_test.go @@ -119,6 +119,7 @@ func TestDefaultDeleteOnSuspendHandler(t *testing.T) { func TestDefaultSuspendMutationHandler(t *testing.T) { job := &batchv1.Job{} mutator := NewMutator(job) + mutator.BeginFeature() err := DefaultSuspendMutationHandler(mutator) require.NoError(t, err) err = mutator.Apply() diff --git a/pkg/primitives/job/mutator.go b/pkg/primitives/job/mutator.go index dbbc07fc..3282b4b6 100644 --- a/pkg/primitives/job/mutator.go +++ b/pkg/primitives/job/mutator.go @@ -51,13 +51,14 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given Job. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the Job. BeginFeature must be called before registering +// any mutations. func NewMutator(current *batchv1.Job) *Mutator { - m := &Mutator{ + return &Mutator{ current: current, - plans: []featurePlan{{}}, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/job/mutator_test.go b/pkg/primitives/job/mutator_test.go index a21fc1be..7d58eed0 100644 --- a/pkg/primitives/job/mutator_test.go +++ b/pkg/primitives/job/mutator_test.go @@ -18,6 +18,72 @@ func TestNewMutator(t *testing.T) { m := NewMutator(job) assert.NotNil(t, m) assert.Equal(t, job, 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) { + job := &batchv1.Job{} + m := NewMutator(job) + + 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) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + m := NewMutator(job) + + // Record mutations in the first feature plan + m.BeginFeature() + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + return nil + }) + 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.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(5) + return nil + }) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].jobSpecEdits, 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].jobSpecEdits, 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) { + job := &batchv1.Job{} + m := NewMutator(job) + m.BeginFeature() + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, int32(3), *job.Spec.BackoffLimit) } func TestMutator_EnvVars(t *testing.T) { @@ -41,6 +107,7 @@ func TestMutator_EnvVars(t *testing.T) { } m := NewMutator(job) + m.BeginFeature() m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) m.RemoveContainerEnvVar("REMOVE") @@ -82,6 +149,7 @@ func TestMutator_EditContainers(t *testing.T) { } m := NewMutator(job) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "c1-image" return nil @@ -103,6 +171,7 @@ func TestMutator_EditContainers(t *testing.T) { func TestMutator_EditPodSpec(t *testing.T) { job := &batchv1.Job{} m := NewMutator(job) + m.BeginFeature() m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.Raw().ServiceAccountName = "my-sa" return nil @@ -116,6 +185,7 @@ func TestMutator_EditPodSpec(t *testing.T) { func TestMutator_EditJobSpec(t *testing.T) { job := &batchv1.Job{} m := NewMutator(job) + m.BeginFeature() m.EditJobSpec(func(e *editors.JobSpecEditor) error { e.SetBackoffLimit(5) e.SetCompletions(3) @@ -133,6 +203,7 @@ func TestMutator_EditJobSpec(t *testing.T) { func TestMutator_EditMetadata(t *testing.T) { job := &batchv1.Job{} m := NewMutator(job) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Labels = map[string]string{"job": "label"} return nil @@ -151,6 +222,7 @@ func TestMutator_EditMetadata(t *testing.T) { func TestMutator_Errors(t *testing.T) { job := &batchv1.Job{} m := NewMutator(job) + m.BeginFeature() m.EditPodSpec(func(_ *editors.PodSpecEditor) error { return errors.New("boom") }) @@ -177,6 +249,7 @@ func TestMutator_Order(t *testing.T) { var order []string m := NewMutator(job) + m.BeginFeature() // Register in reverse order to verify fixed category ordering m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { order = append(order, "container") @@ -221,6 +294,7 @@ func TestMutator_ContainerPresence(t *testing.T) { } m := NewMutator(job) + 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"}) @@ -249,6 +323,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { } m := NewMutator(job) + m.BeginFeature() m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) @@ -260,6 +335,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { } func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" job := &batchv1.Job{ Spec: batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ @@ -273,10 +349,11 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { } m := NewMutator(job) + m.BeginFeature() // First edit renames the container m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { - e.Raw().Name = "app-v2" + e.Raw().Name = appV2 return nil }) @@ -287,7 +364,7 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { }) // Third edit targeting "app-v2" should NOT match in this apply pass - m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + m.EditContainers(selectors.ContainerNamed(appV2), func(e *editors.ContainerEditor) error { e.Raw().Image = "should-not-be-set" return nil }) @@ -295,7 +372,7 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { err := m.Apply() require.NoError(t, err) - assert.Equal(t, "app-v2", job.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, appV2, job.Spec.Template.Spec.Containers[0].Name) assert.Equal(t, "app-image-updated", job.Spec.Template.Spec.Containers[0].Image) } @@ -310,6 +387,7 @@ func TestMutator_NilSafety(t *testing.T) { }, } m := NewMutator(job) + m.BeginFeature() // These should all be no-ops and not panic m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) @@ -368,6 +446,58 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { assert.Equal(t, "v3", job.Spec.Template.Spec.Containers[0].Image) } +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(job) + m.BeginFeature() + + var executionOrder []string + + // We register them 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.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "jobmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "jobmeta", + "jobspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + func TestMutator_CrossFeatureVisibility(t *testing.T) { job := &batchv1.Job{ Spec: batchv1.JobSpec{ @@ -414,6 +544,7 @@ func TestMutator_PresenceBeforeEdit(t *testing.T) { } m := NewMutator(job) + m.BeginFeature() // Register edit first m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { @@ -431,3 +562,73 @@ func TestMutator_PresenceBeforeEdit(t *testing.T) { require.Len(t, job.Spec.Template.Spec.Containers, 1) assert.Equal(t, "edited-image", job.Spec.Template.Spec.Containers[0].Image) } + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + } + + m := NewMutator(job) + m.BeginFeature() + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = newImage + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, newImage, job.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + job := &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(job) + m.BeginFeature() + + // 1. Add init-1 + m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + + // 2. Edit init-1 (it's present in the same feature's phase) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-edited" + return nil + }) + + // 3. Rename it inside the edit phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "init-1-renamed" + return nil + }) + + // 4. Selector targeting "init-1" should still match because of snapshot in same phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-final" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, job.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", job.Spec.Template.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", job.Spec.Template.Spec.InitContainers[0].Image) +} From 7f0e9bfd56f380134df61e46d04826d31d12fc44 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:09:08 +0000 Subject: [PATCH 17/19] Remove field applicators and flavors from job primitive for SSA migration Align the job primitive with the framework's switch to Server-Side Apply: - Remove DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor - Delete flavors.go and flavors_test.go - Remove defaultApplicator parameter from NewTaskBuilder call - Update Mutate tests to use Object() output instead of empty structs - Remove field applicator and flavor sections from documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/job.md | 33 ------ pkg/primitives/job/builder.go | 32 ------ pkg/primitives/job/builder_test.go | 38 ------- pkg/primitives/job/flavors.go | 44 -------- pkg/primitives/job/flavors_test.go | 145 ------------------------- pkg/primitives/job/resource.go | 25 +---- pkg/primitives/job/resource_test.go | 160 ++++------------------------ 7 files changed, 28 insertions(+), 449 deletions(-) delete mode 100644 pkg/primitives/job/flavors.go delete mode 100644 pkg/primitives/job/flavors_test.go diff --git a/docs/primitives/job.md b/docs/primitives/job.md index e914b4e7..9c6c1ded 100644 --- a/docs/primitives/job.md +++ b/docs/primitives/job.md @@ -11,16 +11,6 @@ following the same pod-template mutation pattern as the Deployment primitive. | **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` | | **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` | | **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | - -## Default Field Application - -`DefaultFieldApplicator` replaces the current Job 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 Job Primitive @@ -45,7 +35,6 @@ base := &batchv1.Job{ } resource, err := job.NewBuilder(base). - WithFieldApplicationFlavor(job.PreserveCurrentLabels). WithMutation(MyFeatureMutation(owner.Spec.Version)). Build() ``` @@ -248,28 +237,6 @@ resource, err := job.NewBuilder(base). Build() ``` -## Flavors - -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external -controllers or other tools. - -### PreserveCurrentLabels - -Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. - -### PreserveCurrentAnnotations - -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on -overlap. - -### PreserveCurrentPodTemplateLabels - -Preserves labels on the live object's pod template that are absent from the applied desired state. - -### PreserveCurrentPodTemplateAnnotations - -Preserves annotations on the live object's pod template that are absent from the applied desired state. - ## Guidance **`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use diff --git a/pkg/primitives/job/builder.go b/pkg/primitives/job/builder.go index ee552402..228c4c42 100644 --- a/pkg/primitives/job/builder.go +++ b/pkg/primitives/job/builder.go @@ -34,7 +34,6 @@ func NewBuilder(job *batchv1.Job) *Builder { base := generic.NewTaskBuilder[*batchv1.Job, *Mutator]( job, identityFunc, - DefaultFieldApplicator, NewMutator, ) @@ -63,37 +62,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing Job in the cluster. -// -// There is a default field applicator (DefaultFieldApplicator) that overwrites -// the entire spec of the current object with the desired state. Using a custom -// applicator is necessary when external controllers manage specific fields that -// should not be overwritten. -// -// If a custom applicator is set, it overrides the default baseline application -// logic. Post-application flavors and mutations are still applied afterward. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current *batchv1.Job, desired *batchv1.Job) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a reusable post-application "flavor" for -// the Job. -// -// Flavors are applied in the order they are registered, after the baseline field -// applicator (default or custom) has already run. They are typically used to -// preserve selected live fields from the current object that should not be -// overwritten by the desired state. -// -// If the provided flavor is nil, it is ignored. -func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { - b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*batchv1.Job](flavor)) - return b -} - // WithCustomConvergeStatus overrides the default logic for determining if the // Job has completed, is running, or has failed. // diff --git a/pkg/primitives/job/builder_test.go b/pkg/primitives/job/builder_test.go index db213759..d0133340 100644 --- a/pkg/primitives/job/builder_test.go +++ b/pkg/primitives/job/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() - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - }, - } - applied := false - applicator := func(_ *batchv1.Job, _ *batchv1.Job) error { - applied = true - return nil - } - res, err := NewBuilder(job). - 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() - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - }, - } - res, err := NewBuilder(job). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(nil). - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) - }) - t.Run("WithCustomConvergeStatus", func(t *testing.T) { t.Parallel() job := &batchv1.Job{ diff --git a/pkg/primitives/job/flavors.go b/pkg/primitives/job/flavors.go deleted file mode 100644 index 254ff698..00000000 --- a/pkg/primitives/job/flavors.go +++ /dev/null @@ -1,44 +0,0 @@ -package job - -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.Job] - -// PreserveCurrentLabels ensures that any labels present on the current live -// Job 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.Job) error { - return flavors.PreserveCurrentLabels[*batchv1.Job]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live Job 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.Job) error { - return flavors.PreserveCurrentAnnotations[*batchv1.Job]()(applied, current, desired) -} - -// PreserveCurrentPodTemplateLabels ensures that any labels present on the -// current live Job'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.Job) error { - applied.Spec.Template.Labels = utils.PreserveMap(applied.Spec.Template.Labels, current.Spec.Template.Labels) - return nil -} - -// PreserveCurrentPodTemplateAnnotations ensures that any annotations present -// on the current live Job'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.Job) error { - applied.Spec.Template.Annotations = utils.PreserveMap(applied.Spec.Template.Annotations, current.Spec.Template.Annotations) - return nil -} diff --git a/pkg/primitives/job/flavors_test.go b/pkg/primitives/job/flavors_test.go deleted file mode 100644 index 3313cdcf..00000000 --- a/pkg/primitives/job/flavors_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package job - -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.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - Labels: map[string]string{"app": "desired"}, - }, - } - - t.Run("flavors run after baseline applicator", func(t *testing.T) { - current := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - Labels: map[string]string{"extra": "preserved"}, - }, - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(PreserveCurrentLabels). - Build() - require.NoError(t, err) - require.NotNil(t, res) - - 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.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - }, - } - - var order []string - flavor1 := func(_, _, _ *batchv1.Job) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *batchv1.Job) error { - order = append(order, "flavor2") - return nil - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor1). - WithFieldApplicationFlavor(flavor2). - Build() - require.NoError(t, err) - require.NotNil(t, res) - - 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.Job{} - flavorErr := errors.New("boom") - flavor := func(_, _, _ *batchv1.Job) error { - return flavorErr - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor). - Build() - require.NoError(t, err) - require.NotNil(t, res) - - 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.Job{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} - current := &batchv1.Job{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.Job{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &batchv1.Job{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.Job{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}} - current := &batchv1.Job{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.Template.Labels["keep"]) - assert.Equal(t, "current", applied.Spec.Template.Labels["extra"]) - }) - - t.Run("PreserveCurrentPodTemplateAnnotations", func(t *testing.T) { - applied := &batchv1.Job{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}} - current := &batchv1.Job{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.Template.Annotations["keep"]) - assert.Equal(t, "current", applied.Spec.Template.Annotations["extra"]) - }) - - t.Run("handles nil maps safely", func(t *testing.T) { - applied := &batchv1.Job{} - current := &batchv1.Job{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/job/resource.go b/pkg/primitives/job/resource.go index 8842d22e..f1690ef3 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -7,18 +7,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.Job) 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 Job within a controller's // reconciliation loop. // @@ -59,18 +47,15 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes Job 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. Field-Application Flavors: Any configured field-application flavors are -// applied on top of the baseline field application to adjust how fields are -// merged or preserved. -// 3. Feature Mutations: All registered feature-based mutations are applied, +// 1. Core State: The desired base state is applied to the current object. +// 2. Feature Mutations: All registered feature-based mutations are applied, // allowing for granular, version-gated changes to the Job. -// 4. Suspension: If the resource is in a suspending state, the suspension +// 3. Suspension: If the resource is in a suspending state, the suspension // logic (e.g., setting suspend=true) is applied. // // This method is invoked by the framework during the "Update" phase of -// reconciliation. +// reconciliation. It ensures that the in-memory object reflects all +// configuration and feature requirements before it is sent to the API server. func (r *Resource) Mutate(current client.Object) error { return r.base.Mutate(current) } diff --git a/pkg/primitives/job/resource_test.go b/pkg/primitives/job/resource_test.go index f860900c..63c242c7 100644 --- a/pkg/primitives/job/resource_test.go +++ b/pkg/primitives/job/resource_test.go @@ -64,10 +64,12 @@ func TestResource_Mutate(t *testing.T) { res, err := NewBuilder(desired).Build() require.NoError(t, err) - current := &batchv1.Job{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, "busybox", current.Spec.Template.Spec.Containers[0].Image) + got := obj.(*batchv1.Job) + assert.Equal(t, "busybox", got.Spec.Template.Spec.Containers[0].Image) } func TestResource_Mutate_WithMutation(t *testing.T) { @@ -84,10 +86,12 @@ func TestResource_Mutate_WithMutation(t *testing.T) { Build() require.NoError(t, err) - current := &batchv1.Job{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, "BAR", current.Spec.Template.Spec.Containers[0].Env[0].Value) + got := obj.(*batchv1.Job) + assert.Equal(t, "BAR", got.Spec.Template.Spec.Containers[0].Env[0].Value) } func TestResource_Mutate_FeatureOrdering(t *testing.T) { @@ -120,11 +124,13 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Build() require.NoError(t, err) - current := &batchv1.Job{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - require.NotNil(t, current.Spec.BackoffLimit) - assert.Equal(t, int32(10), *current.Spec.BackoffLimit) + got := obj.(*batchv1.Job) + require.NotNil(t, got.Spec.BackoffLimit) + assert.Equal(t, int32(10), *got.Spec.BackoffLimit) } func TestResource_Mutate_CrossMutationSelectorSnapshot(t *testing.T) { @@ -155,14 +161,16 @@ func TestResource_Mutate_CrossMutationSelectorSnapshot(t *testing.T) { Build() require.NoError(t, err) - current := &batchv1.Job{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*batchv1.Job) // The sidecar container should exist and have the env var from the second mutation. var sidecar *corev1.Container - for i := range current.Spec.Template.Spec.Containers { - if current.Spec.Template.Spec.Containers[i].Name == "sidecar" { - sidecar = ¤t.Spec.Template.Spec.Containers[i] + for i := range got.Spec.Template.Spec.Containers { + if got.Spec.Template.Spec.Containers[i].Name == "sidecar" { + sidecar = &got.Spec.Template.Spec.Containers[i] break } } @@ -359,128 +367,6 @@ func TestResource_ExtractData_Error(t *testing.T) { assert.Contains(t, err.Error(), "extract error") } -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - 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.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - Labels: map[string]string{"app": "test"}, - }, - Spec: batchv1.JobSpec{ - BackoffLimit: int32Ptr(5), - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired spec and labels are applied - require.NotNil(t, current.Spec.BackoffLimit) - assert.Equal(t, int32(5), *current.Spec.BackoffLimit) - 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) { - current := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - }, - Status: batchv1.JobStatus{ - Active: 2, - Succeeded: 1, - Failed: 0, - }, - } - desired := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: "test-ns", - }, - Spec: batchv1.JobSpec{ - BackoffLimit: int32Ptr(5), - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired spec is applied - require.NotNil(t, current.Spec.BackoffLimit) - assert.Equal(t, int32(5), *current.Spec.BackoffLimit) - - // Status from the live object is preserved - assert.Equal(t, int32(2), current.Status.Active) - assert.Equal(t, int32(1), current.Status.Succeeded) - assert.Equal(t, int32(0), current.Status.Failed) -} - -func TestResource_CustomFieldApplicator(t *testing.T) { - desired := newValidJob() - desired.Labels = map[string]string{"app": "test"} - - applicatorCalled := false - res, err := NewBuilder(desired). - WithCustomFieldApplicator(func(current *batchv1.Job, desired *batchv1.Job) error { - applicatorCalled = true - current.Name = desired.Name - current.Namespace = desired.Namespace - current.Spec = *desired.Spec.DeepCopy() - return nil - }). - Build() - require.NoError(t, err) - - current := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"external": "label"}, - }, - } - err = res.Mutate(current) - require.NoError(t, err) - - assert.True(t, applicatorCalled) - assert.Equal(t, "busybox", current.Spec.Template.Spec.Containers[0].Image) - assert.Equal(t, "label", current.Labels["external"], "External label should be preserved") - assert.NotContains(t, current.Labels, "app", "Desired label should NOT be applied by custom applicator") - - t.Run("returns error", func(t *testing.T) { - res, err := NewBuilder(desired). - WithCustomFieldApplicator(func(_ *batchv1.Job, _ *batchv1.Job) error { - return errors.New("applicator error") - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&batchv1.Job{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "applicator error") - }) -} - func int32Ptr(i int32) *int32 { return &i } From 5736a4ba509a4fb0e18bdd6df9c3acba169173f5 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:52:54 +0000 Subject: [PATCH 18/19] Remove flavors.go and update example references for SSA migration The previous commit removed field applicators and flavors from the job primitive but left the example files unstaged, causing lint failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/job-primitive/README.md | 3 --- examples/job-primitive/features/flavors.go | 16 ---------------- examples/job-primitive/features/mutations.go | 1 + examples/job-primitive/resources/job.go | 10 +++------- 4 files changed, 4 insertions(+), 26 deletions(-) delete mode 100644 examples/job-primitive/features/flavors.go diff --git a/examples/job-primitive/README.md b/examples/job-primitive/README.md index 0eec4ed4..a9532c98 100644 --- a/examples/job-primitive/README.md +++ b/examples/job-primitive/README.md @@ -6,8 +6,6 @@ manage a Kubernetes Job as a component of a larger application, utilizing featur - **Base Construction**: Initializing a Job with basic metadata, spec, and restart policy. - **Feature Mutations**: Applying version-gated or conditional changes (env vars, image version, retry policies) 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 `ConvergingStatus` interface using the `WithCustomConvergeStatus` builder option. - **Suspension**: Demonstrating how Jobs are suspended (deleted by default) when the component is suspended. @@ -18,7 +16,6 @@ manage a Kubernetes Job as a component of a larger application, utilizing featur - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - `mutations.go`: tracing env vars, retry policies, and version-based image updates. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. - `status.go`: implementation of a custom handler for completion status. - `resources/`: Contains the central `NewJobResource` factory that assembles all features using the `job.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. diff --git a/examples/job-primitive/features/flavors.go b/examples/job-primitive/features/flavors.go deleted file mode 100644 index 337ff4ca..00000000 --- a/examples/job-primitive/features/flavors.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package features provides sample features for the job primitive. -package features - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" -) - -// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. -func PreserveLabelsFlavor() job.FieldApplicationFlavor { - return job.PreserveCurrentLabels -} - -// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. -func PreserveAnnotationsFlavor() job.FieldApplicationFlavor { - return job.PreserveCurrentAnnotations -} diff --git a/examples/job-primitive/features/mutations.go b/examples/job-primitive/features/mutations.go index 0f4fd40a..a5a21b45 100644 --- a/examples/job-primitive/features/mutations.go +++ b/examples/job-primitive/features/mutations.go @@ -1,3 +1,4 @@ +// Package features provides sample features for the job primitive. package features import ( diff --git a/examples/job-primitive/resources/job.go b/examples/job-primitive/resources/job.go index 631be88d..e1f85ada 100644 --- a/examples/job-primitive/resources/job.go +++ b/examples/job-primitive/resources/job.go @@ -53,14 +53,10 @@ func NewJobResource(owner *app.ExampleApp) (component.Resource, error) { builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) builder.WithMutation(features.RetryPolicyFeature(owner.Spec.Version)) - // 4. Configure flavors (e.g., preserve labels/annotations if they were modified externally). - builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) - builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) - - // 5. Configure custom status handler. + // 4. Configure custom status handler. builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) - // 6. Data extraction (optional). + // 5. Data extraction (optional). builder.WithDataExtractor(func(j batchv1.Job) error { fmt.Printf("Reconciling job: %s, active: %d, succeeded: %d, failed: %d\n", j.Name, j.Status.Active, j.Status.Succeeded, j.Status.Failed) @@ -75,6 +71,6 @@ func NewJobResource(owner *app.ExampleApp) (component.Resource, error) { return nil }) - // 7. Build the final resource. + // 6. Build the final resource. return builder.Build() } From 639d12a3e2fa03d741e7ff5e3d72923989aff77b 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:14:46 +0000 Subject: [PATCH 19/19] Fix ConvergingStatus doc comment to use actual CompletionStatus constant names Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/job/resource.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/job/resource.go b/pkg/primitives/job/resource.go index f1690ef3..9ed3a9ad 100644 --- a/pkg/primitives/job/resource.go +++ b/pkg/primitives/job/resource.go @@ -65,9 +65,10 @@ func (r *Resource) Mutate(current client.Object) error { // By default, it uses DefaultConvergingStatusHandler, which checks the Job's status // conditions for Complete or Failed. // -// The return value includes a descriptive status (Completed, TaskRunning, TaskPending, -// or TaskFailing) and a human-readable reason, which are used to update the component's -// conditions. +// The return value includes a descriptive status (concepts.CompletionStatusCompleted, +// concepts.CompletionStatusRunning, concepts.CompletionStatusPending, or +// concepts.CompletionStatusFailing) and a human-readable reason, which are used to +// update the component's conditions. func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.CompletionStatusWithReason, error) { return r.base.ConvergingStatus(op) }