diff --git a/.golangci.yml b/.golangci.yml index 4da2479f..c8d26213 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,24 +4,21 @@ run: timeout: 5m linters: - disable-all: true + default: none enable: - - errcheck - - govet - - ineffassign - - staticcheck - - unused - asciicheck - bidichk - bodyclose - dupl + - errcheck - errname - errorlint - goconst - gocritic - gocyclo - godot - - revive + - govet + - ineffassign - misspell - nakedret - nilerr @@ -30,47 +27,56 @@ linters: - prealloc - predeclared - reassign + - revive + - staticcheck - unconvert - unparam + - unused - whitespace - + settings: + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + exclusions: + generated: lax + rules: + - linters: + - dupl + - goconst + - revive + path: _test\.go + - linters: + - revive + text: dot-imports + paths: + - third_party$ + - builtin$ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 formatters: enable: - gofmt - goimports - -issues: - exclude-rules: - - path: _test\.go - linters: - - dupl - - goconst - - revive - - text: "dot-imports" - linters: - - revive - exclude-use-default: false - max-issues-per-linter: 0 - max-same-issues: 0 - -linters-settings: - revive: - rules: - - name: blank-imports - - name: context-as-first-argument - - name: context-keys-type - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - - name: if-return - - name: increment-decrement - - name: var-naming - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow - - name: errorf + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ diff --git a/.tool-versions b/.tool-versions index 6edfef3b..43e7b917 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -golang 1.26.1 +golang 1.25.6 golangci-lint 2.11.2 nodejs 25.1.0 diff --git a/Makefile b/Makefile index 8d3b914a..f1c483d9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all -all: fmt fmt-md lint test build-examples +all: fmt lint test build-examples ##@ General diff --git a/docs/primitives.md b/docs/primitives.md index a1875bf8..675f0fe2 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -147,6 +147,7 @@ have been applied. This means a single mutation can safely add a container and t | Primitive | Category | Documentation | | ----------------------------------- | ----------- | --------------------------------------------------------- | | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | | `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) | | `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md new file mode 100644 index 00000000..0e3aae12 --- /dev/null +++ b/docs/primitives/cronjob.md @@ -0,0 +1,245 @@ +# CronJob Primitive + +The `cronjob` primitive is the framework's built-in integration abstraction for managing Kubernetes `CronJob` resources. +It integrates with the component lifecycle through the Operational and Suspendable concepts, and provides a rich +mutation API for managing the CronJob schedule, job template, pod spec, and containers. + +## Capabilities + +| Capability | Detail | +| ------------------------ | ------------------------------------------------------------------------------------------- | +| **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) | +| **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers | + +## Server-Side Apply + +The CronJob primitive reconciles resources using **Server-Side Apply** (SSA). Only fields declared by the operator are +sent; server-managed defaults, fields set by other controllers, and values written by webhooks are left untouched. Field +ownership is tracked automatically by the Kubernetes API server. + +## Building a CronJob Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" + +base := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-cleanup", + Namespace: owner.Namespace, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "cleanup", Image: "cleanup:latest"}, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, +} + +resource, err := cronjob.NewBuilder(base). + WithMutation(MyScheduleMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `CronJob` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. + +```go +func MyScheduleMutation(version string) cronjob.Mutation { + return cronjob.Mutation{ + Name: "my-schedule", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *cronjob.Mutator) error { + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 */6 * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + return nil + }) + return nil + }, + } +} +``` + +### Boolean-gated mutations + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TimeZoneMutation(version string, enabled bool) cronjob.Mutation { + return cronjob.Mutation{ + Name: "timezone", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *cronjob.Mutator) error { + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetTimeZone("America/New_York") + return nil + }) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded. + +| Step | Category | What it affects | +| ---- | --------------------------- | --------------------------------------------------------------------------------------- | +| 1 | CronJob metadata edits | Labels and annotations on the `CronJob` object | +| 2 | CronJobSpec edits | Schedule, concurrency policy, time zone, history limits | +| 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL | +| 4 | Pod template metadata edits | Labels and annotations on the pod template | +| 5 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 6 | Regular container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.containers` | +| 7 | Regular container edits | Env vars, args, resources (snapshot taken after step 6) | +| 8 | Init container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.initContainers` | +| 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) | + +Container edits (steps 7 and 9) are evaluated against a snapshot taken _after_ presence operations in the same mutation. +This means a single mutation can add a container and then configure it without selector resolution issues. + +## Editors + +### CronJobSpecEditor + +Controls CronJob-level settings via `m.EditCronJobSpec`. + +Available methods: `SetSchedule`, `SetConcurrencyPolicy`, `SetStartingDeadlineSeconds`, `SetSuccessfulJobsHistoryLimit`, +`SetFailedJobsHistoryLimit`, `SetTimeZone`, `Raw`. + +```go +m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 2 * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + e.SetFailedJobsHistoryLimit(1) + return nil +}) +``` + +Note: no typed helper is provided for `spec.suspend`; it can be set via `Raw()` if needed, but suspension should +typically be handled via the framework's suspend mechanism. + +### JobSpecEditor + +Controls the embedded job template spec via `m.EditJobSpec`. + +Available methods: `SetCompletions`, `SetParallelism`, `SetBackoffLimit`, `SetActiveDeadlineSeconds`, +`SetTTLSecondsAfterFinished`, `SetCompletionMode`, `Raw`. + +```go +m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetTTLSecondsAfterFinished(3600) + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("cleanup-sa") + e.Raw().RestartPolicy = corev1.RestartPolicyOnFailure + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a +[selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "DRY_RUN", Value: "false"}) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `CronJob` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +## Convenience Methods + +The `Mutator` also exposes convenience wrappers that target all containers at once: + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Operational Status + +The CronJob primitive reports operational status based on the CronJob's scheduling history: + +| Status | Condition | +| ------------------ | -------------------------------- | +| `OperationPending` | `Status.LastScheduleTime == nil` | +| `Operational` | `Status.LastScheduleTime != nil` | + +Failures are reported on the spawned Job resources, not on the CronJob itself. + +## Suspension + +When the component is suspended, the CronJob primitive sets `spec.suspend = true`. This prevents the CronJob controller +from creating new Job objects. Existing active jobs continue to run. + +| Status | Condition | +| ------------ | ---------------------------------------------------- | +| `Suspended` | `spec.suspend == true` and no active jobs | +| `Suspending` | `spec.suspend == true` but active jobs still running | +| `Suspending` | Waiting for suspend flag to be applied | + +On unsuspend, the desired state (without `spec.suspend = true`) is applied via SSA, allowing the CronJob to resume +scheduling. + +The CronJob is never deleted on suspend (`DeleteOnSuspend = false`). + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/e2e/framework/crd.go b/e2e/framework/crd.go index 7ecbe2ad..fa700fae 100644 --- a/e2e/framework/crd.go +++ b/e2e/framework/crd.go @@ -64,10 +64,7 @@ func (t *TestApp) DeepCopy() *TestApp { // DeepCopyObject implements runtime.Object. func (t *TestApp) DeepCopyObject() runtime.Object { - if c := t.DeepCopy(); c != nil { - return c - } - return nil + return t.DeepCopy() } // TestAppList contains a list of TestApp. @@ -104,10 +101,7 @@ func (t *TestAppList) DeepCopy() *TestAppList { // DeepCopyObject implements runtime.Object. func (t *TestAppList) DeepCopyObject() runtime.Object { - if c := t.DeepCopy(); c != nil { - return c - } - return nil + return t.DeepCopy() } var ( diff --git a/examples/cronjob-primitive/README.md b/examples/cronjob-primitive/README.md new file mode 100644 index 00000000..b6996d4d --- /dev/null +++ b/examples/cronjob-primitive/README.md @@ -0,0 +1,34 @@ +# CronJob Primitive Example + +This example demonstrates the usage of the `cronjob` primitive within the operator component framework. It shows how to +manage a Kubernetes CronJob as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a CronJob with a schedule, job template, and containers. +- **Feature Mutations**: Applying conditional changes (tracing env vars, metrics annotations, version-based image + updates) using the `Mutator`. +- **Suspension**: Suspending the CronJob by setting `spec.suspend = true`. +- **Data Extraction**: Harvesting information from the reconciled resource. + +## Directory Structure + +- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. +- `features/`: Contains modular feature definitions: + - `mutations.go`: tracing env vars, metrics annotations, and version-based image updates. +- `resources/`: Contains the central `NewCronJobResource` factory that assembles all features using the + `cronjob.Builder`. +- `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/cronjob-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components through multiple spec changes. +4. Print the resulting status conditions. diff --git a/examples/cronjob-primitive/app/controller.go b/examples/cronjob-primitive/app/controller.go new file mode 100644 index 00000000..b5c469a1 --- /dev/null +++ b/examples/cronjob-primitive/app/controller.go @@ -0,0 +1,69 @@ +// Package app provides a sample controller using the cronjob primitive. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewCronJobResource is a factory function to create the cronjob resource. + NewCronJobResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + // 1. Build the cronjob resource for this owner. + cronJobResource, err := r.NewCronJobResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the cronjob. + comp, err := component.NewComponentBuilder(). + WithName("example-cronjob"). + WithConditionType("CronJobReady"). + WithResource(cronJobResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/cronjob-primitive/features/mutations.go b/examples/cronjob-primitive/features/mutations.go new file mode 100644 index 00000000..710a2345 --- /dev/null +++ b/examples/cronjob-primitive/features/mutations.go @@ -0,0 +1,68 @@ +// Package features contains example CronJob mutation features. +package features + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds tracing environment variables to all containers. +func TracingFeature(enabled bool) cronjob.Mutation { + return cronjob.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *cronjob.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "JAEGER_AGENT_HOST", + Value: "localhost", + }) + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "TRACING_ENABLED", + Value: "true", + }) + return nil + }, + } +} + +// MetricsFeature adds metrics annotations to the pod template. +func MetricsFeature(enabled bool) cronjob.Mutation { + return cronjob.Mutation{ + Name: "Metrics", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *cronjob.Mutator) error { + m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("prometheus.io/scrape", "true") + meta.EnsureAnnotation("prometheus.io/port", "9090") + return nil + }) + return nil + }, + } +} + +// VersionFeature sets the image version and a label. +func VersionFeature(version string) cronjob.Mutation { + return cronjob.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *cronjob.Mutator) error { + m.EditContainers(selectors.ContainerNamed("worker"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-worker:%s", version) + return nil + }) + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} diff --git a/examples/cronjob-primitive/main.go b/examples/cronjob-primitive/main.go new file mode 100644 index 00000000..96705a5f --- /dev/null +++ b/examples/cronjob-primitive/main.go @@ -0,0 +1,122 @@ +// Package main is the entry point for the cronjob primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/resources" + batchv1 "k8s.io/api/batch/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client for the example. + scheme := runtime.NewScheme() + if err := app.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := batchv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add batch/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + // Pass the cronjob resource factory. + NewCronJobResource: resources.NewCronJobResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: true, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/cronjob-primitive/resources/cronjob.go b/examples/cronjob-primitive/resources/cronjob.go new file mode 100644 index 00000000..12cd20dc --- /dev/null +++ b/examples/cronjob-primitive/resources/cronjob.go @@ -0,0 +1,77 @@ +// Package resources provides resource implementations for the cronjob primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewCronJobResource constructs a cronjob primitive resource with all the features. +func NewCronJobResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base CronJob object. + base := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-cronjob", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + ConcurrencyPolicy: batchv1.ForbidConcurrent, + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "worker", + Image: "my-worker:latest", // Will be overwritten by VersionFeature + }, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, + } + + // 2. Initialize the cronjob builder. + builder := cronjob.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.VersionFeature(owner.Spec.Version)) + builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) + builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics)) + + // 4. Data extraction (optional). + builder.WithDataExtractor(func(cj batchv1.CronJob) error { + fmt.Printf("Reconciling CronJob: %s, schedule: %s\n", cj.Name, cj.Spec.Schedule) + + y, err := yaml.Marshal(cj) + if err != nil { + return fmt.Errorf("failed to marshal cronjob to yaml: %w", err) + } + fmt.Printf("Complete CronJob Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/internal/generic/builder_integration_test.go b/internal/generic/builder_integration_test.go index 7c671d68..814cbef4 100644 --- a/internal/generic/builder_integration_test.go +++ b/internal/generic/builder_integration_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/internal/generic/builder_task_test.go b/internal/generic/builder_task_test.go index c0a7a7e9..c75797bb 100644 --- a/internal/generic/builder_task_test.go +++ b/internal/generic/builder_task_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/internal/generic/mutate_helper.go b/internal/generic/mutate_helper.go index cd2e78a1..19e5e393 100644 --- a/internal/generic/mutate_helper.go +++ b/internal/generic/mutate_helper.go @@ -25,6 +25,7 @@ func ApplyMutations[T client.Object, M MutatorApplier]( mutator := newMutator(currentTyped) fm, isFeatureMutator := any(mutator).(FeatureMutator) + // BeginFeature is called before each mutation to create a new planning scope. for _, mutation := range mutations { if isFeatureMutator { fm.BeginFeature() diff --git a/internal/generic/mutate_helper_test.go b/internal/generic/mutate_helper_test.go index a2ce3f04..fd959bc5 100644 --- a/internal/generic/mutate_helper_test.go +++ b/internal/generic/mutate_helper_test.go @@ -83,3 +83,78 @@ func TestApplyMutationsOrder(t *testing.T) { assert.Equal(t, event, recorder.events[i], "at index %d", i) } } + +func TestApplyMutationsOrder_MultipleMutations(t *testing.T) { + recorder := &orderRecorder{} + + current := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + newMutator := func(_ *corev1.ConfigMap) *recordingMutator { + recorder.record("newMutator") + return &recordingMutator{recorder: recorder} + } + + mutations := []Mutation[*recordingMutator]{ + { + Name: "feat1", + Feature: alwaysEnabled{}, + Mutate: func(_ *recordingMutator) error { + recorder.record("mutation1") + return nil + }, + }, + { + Name: "feat2", + Feature: alwaysEnabled{}, + Mutate: func(_ *recordingMutator) error { + recorder.record("mutation2") + return nil + }, + }, + { + Name: "feat3", + Feature: alwaysEnabled{}, + Mutate: func(_ *recordingMutator) error { + recorder.record("mutation3") + return nil + }, + }, + } + + suspender := func(_ *recordingMutator) error { + recorder.record("suspender") + return nil + } + + _, err := ApplyMutations[*corev1.ConfigMap, *recordingMutator]( + current, + newMutator, + mutations, + suspender, + ) + + require.NoError(t, err) + + // Each mutation gets its own plan via BeginFeature. + expectedOrder := []string{ + "newMutator", + "mutator.BeginFeature", + "mutation1", + "mutator.BeginFeature", + "mutation2", + "mutator.BeginFeature", + "mutation3", + "mutator.Apply", + "mutator.BeginFeature", + "suspender", + "mutator.Apply", + } + + require.Len(t, recorder.events, len(expectedOrder), "events: %v", recorder.events) + + for i, event := range expectedOrder { + assert.Equal(t, event, recorder.events[i], "at index %d", i) + } +} diff --git a/internal/generic/resource_integration_test.go b/internal/generic/resource_integration_test.go index 10dab39b..84b012d8 100644 --- a/internal/generic/resource_integration_test.go +++ b/internal/generic/resource_integration_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/internal/generic/resource_task_test.go b/internal/generic/resource_task_test.go index f9fc952d..9fc1f20d 100644 --- a/internal/generic/resource_task_test.go +++ b/internal/generic/resource_task_test.go @@ -1,4 +1,3 @@ -//nolint:dupl package generic import ( diff --git a/pkg/component/component_test.go b/pkg/component/component_test.go index 03b856af..53d5b7c3 100644 --- a/pkg/component/component_test.go +++ b/pkg/component/component_test.go @@ -10,8 +10,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - . "github.com/onsi/ginkgo/v2" //nolint:revive - . "github.com/onsi/gomega" //nolint:revive + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" ) diff --git a/pkg/component/suite_test.go b/pkg/component/suite_test.go index addbeb98..fc0880ef 100644 --- a/pkg/component/suite_test.go +++ b/pkg/component/suite_test.go @@ -19,8 +19,8 @@ import ( ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" - . "github.com/onsi/ginkgo/v2" //nolint:revive - . "github.com/onsi/gomega" //nolint:revive + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) var ( diff --git a/pkg/mutation/editors/cronjobspec.go b/pkg/mutation/editors/cronjobspec.go new file mode 100644 index 00000000..4be5a769 --- /dev/null +++ b/pkg/mutation/editors/cronjobspec.go @@ -0,0 +1,56 @@ +package editors + +import ( + batchv1 "k8s.io/api/batch/v1" +) + +// CronJobSpecEditor provides a typed API for mutating a Kubernetes CronJobSpec. +// +// Note: spec.suspend is NOT exposed here — it is managed by the framework's suspension +// system via DefaultSuspendMutationHandler. +type CronJobSpecEditor struct { + spec *batchv1.CronJobSpec +} + +// NewCronJobSpecEditor creates a new CronJobSpecEditor for the given CronJobSpec. +func NewCronJobSpecEditor(spec *batchv1.CronJobSpec) *CronJobSpecEditor { + return &CronJobSpecEditor{spec: spec} +} + +// Raw returns the underlying *batchv1.CronJobSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *CronJobSpecEditor) Raw() *batchv1.CronJobSpec { + return e.spec +} + +// SetSchedule sets the cron schedule expression. +func (e *CronJobSpecEditor) SetSchedule(cron string) { + e.spec.Schedule = cron +} + +// SetConcurrencyPolicy sets the concurrency policy for the CronJob. +func (e *CronJobSpecEditor) SetConcurrencyPolicy(policy batchv1.ConcurrencyPolicy) { + e.spec.ConcurrencyPolicy = policy +} + +// SetStartingDeadlineSeconds sets the optional deadline in seconds for starting the job +// if it misses its scheduled time. +func (e *CronJobSpecEditor) SetStartingDeadlineSeconds(seconds int64) { + e.spec.StartingDeadlineSeconds = &seconds +} + +// SetSuccessfulJobsHistoryLimit sets the number of successful finished jobs to retain. +func (e *CronJobSpecEditor) SetSuccessfulJobsHistoryLimit(n int32) { + e.spec.SuccessfulJobsHistoryLimit = &n +} + +// SetFailedJobsHistoryLimit sets the number of failed finished jobs to retain. +func (e *CronJobSpecEditor) SetFailedJobsHistoryLimit(n int32) { + e.spec.FailedJobsHistoryLimit = &n +} + +// SetTimeZone sets the time zone for the cron schedule. +func (e *CronJobSpecEditor) SetTimeZone(tz string) { + e.spec.TimeZone = &tz +} diff --git a/pkg/mutation/editors/cronjobspec_test.go b/pkg/mutation/editors/cronjobspec_test.go new file mode 100644 index 00000000..1b28b09e --- /dev/null +++ b/pkg/mutation/editors/cronjobspec_test.go @@ -0,0 +1,58 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" +) + +func TestCronJobSpecEditor(t *testing.T) { + t.Run("SetSchedule", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetSchedule("*/5 * * * *") + assert.Equal(t, "*/5 * * * *", spec.Schedule) + }) + + t.Run("SetConcurrencyPolicy", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + assert.Equal(t, batchv1.ForbidConcurrent, spec.ConcurrencyPolicy) + }) + + t.Run("SetStartingDeadlineSeconds", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetStartingDeadlineSeconds(200) + assert.Equal(t, int64(200), *spec.StartingDeadlineSeconds) + }) + + t.Run("SetSuccessfulJobsHistoryLimit", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetSuccessfulJobsHistoryLimit(3) + assert.Equal(t, int32(3), *spec.SuccessfulJobsHistoryLimit) + }) + + t.Run("SetFailedJobsHistoryLimit", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetFailedJobsHistoryLimit(1) + assert.Equal(t, int32(1), *spec.FailedJobsHistoryLimit) + }) + + t.Run("SetTimeZone", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + editor.SetTimeZone("America/New_York") + assert.Equal(t, "America/New_York", *spec.TimeZone) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &batchv1.CronJobSpec{} + editor := NewCronJobSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/mutation/editors/jobspec.go b/pkg/mutation/editors/jobspec.go new file mode 100644 index 00000000..13bd1907 --- /dev/null +++ b/pkg/mutation/editors/jobspec.go @@ -0,0 +1,53 @@ +package editors + +import ( + batchv1 "k8s.io/api/batch/v1" +) + +// JobSpecEditor provides a typed API for mutating a Kubernetes JobSpec. +type JobSpecEditor struct { + spec *batchv1.JobSpec +} + +// NewJobSpecEditor creates a new JobSpecEditor for the given JobSpec. +func NewJobSpecEditor(spec *batchv1.JobSpec) *JobSpecEditor { + return &JobSpecEditor{spec: spec} +} + +// Raw returns the underlying *batchv1.JobSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *JobSpecEditor) Raw() *batchv1.JobSpec { + return e.spec +} + +// SetCompletions sets the desired number of successfully finished pods. +func (e *JobSpecEditor) SetCompletions(n int32) { + e.spec.Completions = &n +} + +// SetParallelism sets the maximum desired number of pods running at any given time. +func (e *JobSpecEditor) SetParallelism(n int32) { + e.spec.Parallelism = &n +} + +// SetBackoffLimit sets the number of retries before marking the job as failed. +func (e *JobSpecEditor) SetBackoffLimit(n int32) { + e.spec.BackoffLimit = &n +} + +// SetActiveDeadlineSeconds sets the duration in seconds relative to the start time +// that the job may be active before it is terminated. +func (e *JobSpecEditor) SetActiveDeadlineSeconds(seconds int64) { + e.spec.ActiveDeadlineSeconds = &seconds +} + +// SetTTLSecondsAfterFinished sets the TTL for cleaning up finished jobs. +func (e *JobSpecEditor) SetTTLSecondsAfterFinished(seconds int32) { + e.spec.TTLSecondsAfterFinished = &seconds +} + +// SetCompletionMode sets the completion mode of the job. +func (e *JobSpecEditor) SetCompletionMode(mode batchv1.CompletionMode) { + e.spec.CompletionMode = &mode +} diff --git a/pkg/mutation/editors/jobspec_test.go b/pkg/mutation/editors/jobspec_test.go new file mode 100644 index 00000000..ff421527 --- /dev/null +++ b/pkg/mutation/editors/jobspec_test.go @@ -0,0 +1,58 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" +) + +func TestJobSpecEditor(t *testing.T) { + t.Run("SetCompletions", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetCompletions(5) + assert.Equal(t, int32(5), *spec.Completions) + }) + + t.Run("SetParallelism", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetParallelism(3) + assert.Equal(t, int32(3), *spec.Parallelism) + }) + + t.Run("SetBackoffLimit", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetBackoffLimit(6) + assert.Equal(t, int32(6), *spec.BackoffLimit) + }) + + t.Run("SetActiveDeadlineSeconds", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetActiveDeadlineSeconds(300) + assert.Equal(t, int64(300), *spec.ActiveDeadlineSeconds) + }) + + t.Run("SetTTLSecondsAfterFinished", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetTTLSecondsAfterFinished(100) + assert.Equal(t, int32(100), *spec.TTLSecondsAfterFinished) + }) + + t.Run("SetCompletionMode", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + editor.SetCompletionMode(batchv1.IndexedCompletion) + assert.Equal(t, batchv1.IndexedCompletion, *spec.CompletionMode) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &batchv1.JobSpec{} + editor := NewJobSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/primitives/cronjob/builder.go b/pkg/primitives/cronjob/builder.go new file mode 100644 index 00000000..8169a7a0 --- /dev/null +++ b/pkg/primitives/cronjob/builder.go @@ -0,0 +1,120 @@ +package cronjob + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + batchv1 "k8s.io/api/batch/v1" +) + +// Builder is a configuration helper for creating and customizing a CronJob Resource. +// +// It provides a fluent API for registering mutations, status handlers, and +// data extractors. This builder ensures that the resulting Resource is +// properly initialized and validated before use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*batchv1.CronJob, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided CronJob object. +// +// The CronJob object passed here serves as the "desired base state". During +// reconciliation, the Resource will attempt to make the cluster's state match +// this base state, modified by any registered mutations. +// +// The provided CronJob must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(cj *batchv1.CronJob) *Builder { + identityFunc := func(c *batchv1.CronJob) string { + return fmt.Sprintf("batch/v1/CronJob/%s/%s", c.Namespace, c.Name) + } + + base := generic.NewIntegrationBuilder[*batchv1.CronJob, *Mutator]( + cj, + identityFunc, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the CronJob. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomOperationalStatus overrides the default logic for determining if the +// CronJob is operational. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *batchv1.CronJob) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +func (b *Builder) WithCustomSuspendStatus( + handler func(*batchv1.CronJob) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the CronJob should be modified when +// the component is suspended. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the CronJob when the component is suspended. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*batchv1.CronJob) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// CronJob after it has been successfully reconciled. +func (b *Builder) WithDataExtractor( + extractor func(batchv1.CronJob) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(c *batchv1.CronJob) error { + return extractor(*c) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base CronJob object was provided. +// - The CronJob has both a name and a namespace set. +// +// If validation fails, an error is returned and the Resource should not be used. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/cronjob/builder_test.go b/pkg/primitives/cronjob/builder_test.go new file mode 100644 index 00000000..598c693c --- /dev/null +++ b/pkg/primitives/cronjob/builder_test.go @@ -0,0 +1,213 @@ +package cronjob + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder(t *testing.T) { + t.Parallel() + + t.Run("Build validation", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cronjob *batchv1.CronJob + expectedErr string + }{ + { + name: "nil cronjob", + cronjob: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + cronjob: &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + cronjob: &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid cronjob", + cronjob: &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.cronjob).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "batch/v1/CronJob/test-ns/test-cronjob", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(cj). + WithMutation(m). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) + }) + + t.Run("WithCustomOperationalStatus", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *batchv1.CronJob) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil + } + res, err := NewBuilder(cj). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ *batchv1.CronJob) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(cj). + WithCustomSuspendStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendStatusHandler) + status, err := res.base.SuspendStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("WithCustomSuspendMutation", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(cj). + WithCustomSuspendMutation(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendMutationHandler) + err = res.base.SuspendMutationHandler(nil) + assert.EqualError(t, err, "suspend error") + }) + + t.Run("WithCustomSuspendDeletionDecision", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + handler := func(_ *batchv1.CronJob) bool { + return true + } + res, err := NewBuilder(cj). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.True(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ batchv1.CronJob) error { + called = true + return nil + } + res, err := NewBuilder(cj). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&batchv1.CronJob{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(cj). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/cronjob/handlers.go b/pkg/primitives/cronjob/handlers.go new file mode 100644 index 00000000..bb1762cf --- /dev/null +++ b/pkg/primitives/cronjob/handlers.go @@ -0,0 +1,77 @@ +package cronjob + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + batchv1 "k8s.io/api/batch/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining if a CronJob is operational. +// +// It considers a CronJob operational when it has scheduled at least once +// (Status.LastScheduleTime is not nil). If it has never been scheduled, +// the status is Pending. +// +// Failures are reported on the spawned Job resources, not on the CronJob itself. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, cj *batchv1.CronJob, +) (concepts.OperationalStatusWithReason, error) { + if cj.Status.LastScheduleTime == nil { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "CronJob has never been scheduled", + }, nil + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: fmt.Sprintf("CronJob last scheduled at %s", cj.Status.LastScheduleTime.UTC().Format("2006-01-02T15:04:05Z")), + }, nil +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a CronJob when the component is suspended. +// +// It sets spec.suspend to true, which prevents the CronJob from creating new Job objects. +func DefaultSuspendMutationHandler(mutator *Mutator) error { + mutator.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + suspend := true + e.Raw().Suspend = &suspend + return nil + }) + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It reports Suspended when spec.suspend is true and no active jobs are running. +// It reports Suspending when spec.suspend is true but active jobs are still running. +func DefaultSuspensionStatusHandler(cj *batchv1.CronJob) (concepts.SuspensionStatusWithReason, error) { + if cj.Spec.Suspend != nil && *cj.Spec.Suspend { + if len(cj.Status.Active) > 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("CronJob suspended but %d active jobs still running", len(cj.Status.Active)), + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "CronJob suspended", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: "Waiting for suspend flag to be applied", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the CronJob +// when the parent component is suspended. +// +// It always returns false, meaning the CronJob is kept in the cluster with spec.suspend set to true. +func DefaultDeleteOnSuspendHandler(_ *batchv1.CronJob) bool { + return false +} diff --git a/pkg/primitives/cronjob/handlers_test.go b/pkg/primitives/cronjob/handlers_test.go new file mode 100644 index 00000000..f911d3f5 --- /dev/null +++ b/pkg/primitives/cronjob/handlers_test.go @@ -0,0 +1,150 @@ +package cronjob + +import ( + "testing" + "time" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + cronjob *batchv1.CronJob + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "pending when never scheduled", + op: concepts.ConvergingOperationCreated, + cronjob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{}, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "CronJob has never been scheduled", + }, + { + name: "operational when scheduled at least once", + op: concepts.ConvergingOperationUpdated, + cronjob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{ + LastScheduleTime: &metav1.Time{Time: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)}, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "CronJob last scheduled at 2025-01-01T12:00:00Z", + }, + { + name: "operational regardless of converging operation", + op: concepts.ConvergingOperationNone, + cronjob: &batchv1.CronJob{ + Status: batchv1.CronJobStatus{ + LastScheduleTime: &metav1.Time{Time: time.Date(2025, 6, 15, 8, 30, 0, 0, time.UTC)}, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "CronJob last scheduled at 2025-06-15T08:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(tt.op, tt.cronjob) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + } + mutator := NewMutator(cj) + mutator.BeginFeature() + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + require.NotNil(t, cj.Spec.Suspend) + assert.True(t, *cj.Spec.Suspend) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + cronjob *batchv1.CronJob + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended with no active jobs", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Suspend: ptr.To(true), + }, + Status: batchv1.CronJobStatus{}, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "CronJob suspended", + }, + { + name: "suspending with active jobs", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Suspend: ptr.To(true), + }, + Status: batchv1.CronJobStatus{ + Active: []corev1.ObjectReference{ + {Name: "job-1"}, + {Name: "job-2"}, + }, + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "CronJob suspended but 2 active jobs still running", + }, + { + name: "suspending when suspend flag not yet applied", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{}, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for suspend flag to be applied", + }, + { + name: "suspending when suspend is false", + cronjob: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Suspend: ptr.To(false), + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for suspend flag to be applied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultSuspensionStatusHandler(tt.cronjob) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + cj := &batchv1.CronJob{} + assert.False(t, DefaultDeleteOnSuspendHandler(cj)) +} diff --git a/pkg/primitives/cronjob/mutator.go b/pkg/primitives/cronjob/mutator.go new file mode 100644 index 00000000..aea741b2 --- /dev/null +++ b/pkg/primitives/cronjob/mutator.go @@ -0,0 +1,407 @@ +package cronjob + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a CronJob Mutator +// only if its associated feature.ResourceFeature is enabled. +type Mutation feature.Mutation[*Mutator] + +type containerEdit struct { + selector selectors.ContainerSelector + edit func(*editors.ContainerEditor) error +} + +type containerPresenceOp struct { + name string + container *corev1.Container // nil for remove +} + +type featurePlan struct { + cronjobMetadataEdits []func(*editors.ObjectMetaEditor) error + cronjobSpecEdits []func(*editors.CronJobSpecEditor) error + jobSpecEdits []func(*editors.JobSpecEditor) error + podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit +} + +// Mutator is a high-level helper for modifying a Kubernetes CronJob. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the CronJob in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +type Mutator struct { + current *batchv1.CronJob + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given CronJob. +// BeginFeature must be called before registering any mutations. +func NewMutator(current *batchv1.CronJob) *Mutator { + return &Mutator{ + current: current, + } +} + +// BeginFeature starts a new feature planning scope. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +func (m *Mutator) requireActive() { + if m.active == nil { + panic("cronjob.Mutator: BeginFeature() must be called before registering mutations") + } +} + +// EditObjectMetadata records a mutation for the CronJob's own metadata. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.requireActive() + m.active.cronjobMetadataEdits = append(m.active.cronjobMetadataEdits, edit) +} + +// EditCronJobSpec records a mutation for the CronJob's top-level spec. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditCronJobSpec(edit func(*editors.CronJobSpecEditor) error) { + if edit == nil { + return + } + m.requireActive() + m.active.cronjobSpecEdits = append(m.active.cronjobSpecEdits, edit) +} + +// EditJobSpec records a mutation for the CronJob's embedded job template spec. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditJobSpec(edit func(*editors.JobSpecEditor) error) { + if edit == nil { + return + } + m.requireActive() + m.active.jobSpecEdits = append(m.active.jobSpecEdits, edit) +} + +// EditPodTemplateMetadata records a mutation for the CronJob's pod template metadata. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.requireActive() + m.active.podTemplateMetadataEdits = append(m.active.podTemplateMetadataEdits, edit) +} + +// EditPodSpec records a mutation for the CronJob's pod spec. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { + if edit == nil { + return + } + m.requireActive() + m.active.podSpecEdits = append(m.active.podSpecEdits, edit) +} + +// EditContainers records a mutation for containers matching the given selector. +// +// Selector matching is evaluated against a snapshot taken after the current feature's +// container presence operations are applied. +// If either selector or edit function is nil, the registration is ignored. +func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.requireActive() + m.active.containerEdits = append(m.active.containerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EditInitContainers records a mutation for init containers matching the given selector. +// +// Selector matching is evaluated against a snapshot taken after the current feature's +// init container presence operations are applied. +// If either selector or edit function is nil, the registration is ignored. +func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.requireActive() + m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureContainer records that a regular container must be present in the CronJob. +// If a container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureContainer(container corev1.Container) { + m.requireActive() + 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.requireActive() + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveContainers records that multiple regular containers should be removed by name. +func (m *Mutator) RemoveContainers(names []string) { + for _, name := range names { + m.RemoveContainer(name) + } +} + +// EnsureInitContainer records that an init container must be present in the CronJob. +// If an init container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.requireActive() + 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.requireActive() + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveInitContainers records that multiple init containers should be removed by name. +func (m *Mutator) RemoveInitContainers(names []string) { + for _, name := range names { + m.RemoveInitContainer(name) + } +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the CronJob. +func (m *Mutator) EnsureContainerEnvVar(ev corev1.EnvVar) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(ev) + return nil + }) +} + +// RemoveContainerEnvVar records that an environment variable should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerEnvVar(name string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVar(name) + return nil + }) +} + +// RemoveContainerEnvVars records that multiple environment variables should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerEnvVars(names []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVars(names) + return nil + }) +} + +// EnsureContainerArg records that a command-line argument must be present +// in all containers of the CronJob. +func (m *Mutator) EnsureContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureArg(arg) + return nil + }) +} + +// RemoveContainerArg records that a command-line argument should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArg(arg) + return nil + }) +} + +// RemoveContainerArgs records that multiple command-line arguments should be +// removed from all containers of the CronJob. +func (m *Mutator) RemoveContainerArgs(args []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArgs(args) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying CronJob. +// +// Execution Order: +// Features are applied in the order they were registered. +// Within each feature, mutations are applied in this fixed category order: +// 1. Object metadata edits +// 2. CronJobSpec edits +// 3. JobSpec edits +// 4. Pod template metadata edits +// 5. Pod spec edits +// 6. Regular container presence operations +// 7. Regular container edits +// 8. Init container presence operations +// 9. Init container edits +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + if err := m.applyPlan(plan); err != nil { + return err + } + } + + return nil +} + +func (m *Mutator) applyPlan(plan featurePlan) error { + // 1. Object metadata + if len(plan.cronjobMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.cronjobMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. CronJobSpec + if len(plan.cronjobSpecEdits) > 0 { + editor := editors.NewCronJobSpecEditor(&m.current.Spec) + for _, edit := range plan.cronjobSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. JobSpec + if len(plan.jobSpecEdits) > 0 { + editor := editors.NewJobSpecEditor(&m.current.Spec.JobTemplate.Spec) + for _, edit := range plan.jobSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 4. Pod template metadata + if len(plan.podTemplateMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.Spec.JobTemplate.Spec.Template.ObjectMeta) + for _, edit := range plan.podTemplateMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 5. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec.JobTemplate.Spec.Template.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 6. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.Containers, op) + } + + // 7. Regular container edits + if err := applyContainerEdits(m.current.Spec.JobTemplate.Spec.Template.Spec.Containers, plan.containerEdits); err != nil { + return err + } + + // 8. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers, op) + } + + // 9. Init container edits + return applyContainerEdits(m.current.Spec.JobTemplate.Spec.Template.Spec.InitContainers, plan.initContainerEdits) +} + +func applyContainerEdits(containers []corev1.Container, edits []containerEdit) error { + if len(edits) == 0 { + return nil + } + + snapshots := make([]corev1.Container, len(containers)) + for i := range containers { + containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range containers { + container := &containers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range edits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + + return nil +} + +func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { + found := -1 + for i, c := range *containers { + if c.Name == op.name { + found = i + break + } + } + + if op.container == nil { + // Remove + if found != -1 { + *containers = append((*containers)[:found], (*containers)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*containers)[found] = *op.container + } else { + *containers = append(*containers, *op.container) + } +} diff --git a/pkg/primitives/cronjob/mutator_test.go b/pkg/primitives/cronjob/mutator_test.go new file mode 100644 index 00000000..a227baa2 --- /dev/null +++ b/pkg/primitives/cronjob/mutator_test.go @@ -0,0 +1,737 @@ +package cronjob + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewMutator(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + assert.NotNil(t, m) + assert.Equal(t, cj, m.current) + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + }, + }, + } + m := NewMutator(cj) + + // Record mutations in the first feature plan + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/5 * * * *") + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1" + return nil + }) + + // Start a new feature + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 * * * *") + return nil + }) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].cronjobSpecEdits, 1, "first plan should have one spec edit") + assert.Len(t, m.plans[0].containerEdits, 1, "first plan should have one container edit") + assert.Len(t, m.plans[1].cronjobSpecEdits, 1, "second plan should have one spec edit") + assert.Empty(t, m.plans[1].containerEdits, "second plan should have no container edits") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/5 * * * *") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "single feature should have exactly one plan") +} + +func TestMutator_EditObjectMetadata(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"cronjob": "label"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", cj.Labels["cronjob"]) +} + +func TestMutator_EditCronJobSpec(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/5 * * * *") + e.SetConcurrencyPolicy(batchv1.ForbidConcurrent) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "*/5 * * * *", cj.Spec.Schedule) + assert.Equal(t, batchv1.ForbidConcurrent, cj.Spec.ConcurrencyPolicy) +} + +func TestMutator_EditJobSpec(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditJobSpec(func(e *editors.JobSpecEditor) error { + e.SetBackoffLimit(3) + e.SetCompletions(1) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, int32(3), *cj.Spec.JobTemplate.Spec.BackoffLimit) + assert.Equal(t, int32(1), *cj.Spec.JobTemplate.Spec.Completions) +} + +func TestMutator_EditPodTemplateMetadata(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Annotations = map[string]string{"pod": "ann"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "ann", cj.Spec.JobTemplate.Spec.Template.Annotations["pod"]) +} + +func TestMutator_EditPodSpec(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.Raw().ServiceAccountName = "my-sa" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "my-sa", cj.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditContainers(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "c1-image" + return nil + }) + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "GLOBAL", Value: "true"}) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "c1-image", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EnvVars(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Env: []corev1.EnvVar{ + {Name: "KEEP", Value: "stay"}, + {Name: "CHANGE", Value: "old"}, + {Name: "REMOVE", Value: "gone"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) + m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) + + err := m.Apply() + require.NoError(t, err) + + env := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env + assert.Len(t, env, 3) + + findEnv := func(name string) *corev1.EnvVar { + for i := range env { + if env[i].Name == name { + return &env[i] + } + } + return nil + } + + assert.NotNil(t, findEnv("KEEP")) + assert.Equal(t, "stay", findEnv("KEEP").Value) + assert.Equal(t, "new", findEnv("CHANGE").Value) + assert.Equal(t, "added", findEnv("ADD").Value) + assert.Nil(t, findEnv("REMOVE")) +} + +func TestMutator_Args(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Args + assert.Contains(t, args, "--keep") + assert.Contains(t, args, "--change=old") + assert.Contains(t, args, "--change=new") + assert.Contains(t, args, "--add") + assert.NotContains(t, args, "--remove") +} + +func TestMutator_ContainerPresence(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + m.RemoveContainer("sidecar") + m.EnsureContainer(corev1.Container{Name: "new-container", Image: "new-image"}) + + err := m.Apply() + require.NoError(t, err) + + containers := cj.Spec.JobTemplate.Spec.Template.Spec.Containers + require.Len(t, containers, 2) + assert.Equal(t, "app", containers[0].Name) + assert.Equal(t, "app-new-image", containers[0].Image) + assert.Equal(t, "new-container", containers[1].Name) + assert.Equal(t, "new-image", containers[1].Image) +} + +func TestMutator_InitContainers(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "new-image" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "new-image", cj.Spec.JobTemplate.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) + m.RemoveInitContainers([]string{"init-1"}) + + err := m.Apply() + require.NoError(t, err) + + initContainers := cj.Spec.JobTemplate.Spec.Template.Spec.InitContainers + require.Len(t, initContainers, 1) + assert.Equal(t, "init-2", initContainers[0].Name) +} + +func TestMutator_Errors(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + return errors.New("boom") + }) + + err := m.Apply() + assert.Error(t, err) + assert.Equal(t, "boom", err.Error()) +} + +func TestMutator_NilSafety(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + }, + }, + } + m := NewMutator(cj) + m.BeginFeature() + + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditInitContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditInitContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditPodTemplateMetadata(nil) + m.EditObjectMetadata(nil) + m.EditCronJobSpec(nil) + m.EditJobSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_Order(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + }, + }, + } + + var order []string + + m := NewMutator(cj) + m.BeginFeature() + // Register in reverse order to verify execution order + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + order = append(order, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + order = append(order, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "podmeta") + return nil + }) + m.EditJobSpec(func(_ *editors.JobSpecEditor) error { + order = append(order, "jobspec") + return nil + }) + m.EditCronJobSpec(func(_ *editors.CronJobSpecEditor) error { + order = append(order, "cronjobspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "cronjobmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"cronjobmeta", "cronjobspec", "jobspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + // Feature A + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("*/10 * * * *") + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B + m.BeginFeature() + m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + e.SetSchedule("0 * * * *") + return nil + }) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "0 * * * *", cj.Spec.Schedule) + assert.Equal(t, "v3", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Should still match using snapshot + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + // Should NOT match in this apply pass + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "should-not-be-set" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "app-v2", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + + // Register edit first + m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "edited-image" + return nil + }) + + // Register presence later + m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) + + err := m.Apply() + require.NoError(t, err) + + containers := cj.Spec.JobTemplate.Spec.Template.Spec.Containers + require.Len(t, containers, 1) + assert.Equal(t, "edited-image", containers[0].Image) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + cj := &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + + // Feature A renames container + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Feature B selects by the new name + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2-image" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "app-v2", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_EditMetadata(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"cronjob": "label"} + return nil + }) + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Annotations = map[string]string{"pod": "ann"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", cj.Labels["cronjob"]) + assert.Equal(t, "ann", cj.Spec.JobTemplate.Spec.Template.Annotations["pod"]) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + }, + }, + } + + m := NewMutator(cj) + m.BeginFeature() + + var executionOrder []string + + // Register in reverse order of expected execution + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + executionOrder = append(executionOrder, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + executionOrder = append(executionOrder, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "podmeta") + return nil + }) + m.EditJobSpec(func(_ *editors.JobSpecEditor) error { + executionOrder = append(executionOrder, "jobspec") + return nil + }) + m.EditCronJobSpec(func(_ *editors.CronJobSpecEditor) error { + executionOrder = append(executionOrder, "cronjobspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "cronjobmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "cronjobmeta", + "cronjobspec", + "jobspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_PanicsWithoutBeginFeature(t *testing.T) { + cj := &batchv1.CronJob{} + m := NewMutator(cj) + + assert.PanicsWithValue(t, "cronjob.Mutator: BeginFeature() must be called before registering mutations", func() { + m.EditObjectMetadata(func(*editors.ObjectMetaEditor) error { return nil }) + }) + + assert.PanicsWithValue(t, "cronjob.Mutator: BeginFeature() must be called before registering mutations", func() { + m.EnsureContainer(corev1.Container{Name: "test"}) + }) +} diff --git a/pkg/primitives/cronjob/resource.go b/pkg/primitives/cronjob/resource.go new file mode 100644 index 00000000..a4f6afdc --- /dev/null +++ b/pkg/primitives/cronjob/resource.go @@ -0,0 +1,81 @@ +// Package cronjob provides a builder and resource for managing Kubernetes CronJobs. +package cronjob + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + batchv1 "k8s.io/api/batch/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes CronJob within a controller's +// reconciliation loop. +// +// It implements several component interfaces to integrate with the operator-component-framework: +// - component.Resource: for basic identity and mutation behavior. +// - component.Operational: for operational status tracking. +// - component.Suspendable: for controlled suspension via spec.suspend. +// - component.DataExtractable: for exporting information after successful reconciliation. +type Resource struct { + base *generic.IntegrationResource[*batchv1.CronJob, *Mutator] +} + +// Identity returns a unique identifier for the CronJob in the format +// "batch/v1/CronJob//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a copy of the underlying Kubernetes CronJob object. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes CronJob into the desired state. +// +// The mutation process follows a specific order: +// 1. Feature Mutations: All registered feature-based mutations are applied. +// 2. Suspension: If the resource is in a suspending state, the suspension +// logic (setting spec.suspend = true) is applied. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus reports the CronJob's operational status. +// +// By default, it uses DefaultOperationalStatusHandler, which reports Pending +// when the CronJob has never been scheduled and Operational when it has +// scheduled at least once. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the CronJob should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it returns false — the CronJob is kept with spec.suspend set to true. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the suspension of the CronJob. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior sets spec.suspend to true. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which reports Suspended +// when spec.suspend is true and no active jobs are running. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled CronJob. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/cronjob/resource_test.go b/pkg/primitives/cronjob/resource_test.go new file mode 100644 index 00000000..8a901762 --- /dev/null +++ b/pkg/primitives/cronjob/resource_test.go @@ -0,0 +1,344 @@ +package cronjob + +import ( + "errors" + "testing" + "time" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidCronJob() *batchv1.CronJob { + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cj", + Namespace: "test-ns", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 2 * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "worker", Image: "worker:latest"}, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidCronJob()).Build() + require.NoError(t, err) + assert.Equal(t, "batch/v1/CronJob/test-ns/test-cj", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + cj := newValidCronJob() + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*batchv1.CronJob) + require.True(t, ok) + assert.Equal(t, cj.Name, got.Name) + assert.Equal(t, cj.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-cj", cj.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidCronJob() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*batchv1.CronJob) + assert.Equal(t, "0 2 * * *", got.Spec.Schedule) + assert.Equal(t, "worker", got.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Name) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidCronJob() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-env", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "FOO", Value: "BAR"}) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*batchv1.CronJob) + assert.Equal(t, "0 2 * * *", got.Spec.Schedule) + assert.Equal(t, "BAR", got.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidCronJob() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error { + if e.Raw().Image == "v2" { + e.Raw().Image = "v3" + } + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*batchv1.CronJob) + assert.Equal(t, "v3", got.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, cj *batchv1.CronJob) (concepts.OperationalStatusWithReason, error) { + args := m.Called(op, cj) + return args.Get(0).(concepts.OperationalStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(cj *batchv1.CronJob) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(cj) + return args.Get(0).(concepts.SuspensionStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) Suspend(mut *Mutator) error { + args := m.Called(mut) + return args.Error(0) +} + +func (m *mockHandlers) DeleteOnSuspend(cj *batchv1.CronJob) bool { + args := m.Called(cj) + return args.Bool(0) +} + +func TestResource_ConvergingStatus(t *testing.T) { + cj := newValidCronJob() + + t.Run("calls custom handler", func(t *testing.T) { + m := &mockHandlers{} + statusOp := concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, cj).Return(statusOp, nil) + + res, err := NewBuilder(cj). + WithCustomOperationalStatus(m.ConvergingStatus). + Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) + + t.Run("uses default - pending", func(t *testing.T) { + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) + }) + + t.Run("uses default - operational", func(t *testing.T) { + cjScheduled := newValidCronJob() + now := metav1.NewTime(time.Now()) + cjScheduled.Status.LastScheduleTime = &now + + res, err := NewBuilder(cjScheduled).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + cj := newValidCronJob() + + t.Run("calls custom handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", cj).Return(true) + + res, err := NewBuilder(cj). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default - false", func(t *testing.T) { + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + cj := newValidCronJob() + + t.Run("registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*batchv1.CronJob) + require.NotNil(t, got.Spec.Suspend) + assert.True(t, *got.Spec.Suspend) + }) + + t.Run("uses custom mutation handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("Suspend", mock.Anything).Return(nil).Run(func(args mock.Arguments) { + mut := args.Get(0).(*Mutator) + mut.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error { + suspend := true + e.Raw().Suspend = &suspend + e.SetSchedule("0 0 31 2 *") // effectively disable + return nil + }) + }) + + res, err := NewBuilder(cj). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*batchv1.CronJob) + m.AssertExpectations(t) + require.NotNil(t, got.Spec.Suspend) + assert.True(t, *got.Spec.Suspend) + assert.Equal(t, "0 0 31 2 *", got.Spec.Schedule) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + t.Run("calls custom handler", func(t *testing.T) { + cj := newValidCronJob() + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", cj).Return(statusSuspended, nil) + + res, err := NewBuilder(cj). + WithCustomSuspendStatus(m.SuspensionStatus). + Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("uses default - suspending when not yet suspended", func(t *testing.T) { + cj := newValidCronJob() + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspending, status.Status) + }) + + t.Run("uses default - suspended when suspend=true and no active jobs", func(t *testing.T) { + cj := newValidCronJob() + suspend := true + cj.Spec.Suspend = &suspend + + res, err := NewBuilder(cj).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) +} + +func TestResource_ExtractData(t *testing.T) { + cj := newValidCronJob() + + var extractedImage string + res, err := NewBuilder(cj). + WithDataExtractor(func(c batchv1.CronJob) error { + extractedImage = c.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "worker:latest", extractedImage) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidCronJob()). + WithDataExtractor(func(_ batchv1.CronJob) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +}