diff --git a/Makefile b/Makefile index cf346ae7..de949571 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,7 @@ build-examples: ## Build all example binaries. run-examples: ## Run all examples to verify they execute without error. go run ./examples/deployment-primitive/. go run ./examples/configmap-primitive/. + go run ./examples/replicaset-primitive/. go run ./examples/rolebinding-primitive/. go run ./examples/custom-resource-implementation/. go run ./examples/hpa-primitive/. diff --git a/docs/primitives.md b/docs/primitives.md index 08431c8f..b8803a17 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -119,6 +119,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `ContainerEditor` | Environment variables, arguments, resource limits, ports | | `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | | `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `ReplicaSetSpecEditor` | Replicas, min ready seconds | | `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | | `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access | | `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw | @@ -148,6 +149,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/replicaset` | Workload | [replicaset.md](primitives/replicaset.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) | @@ -156,7 +158,6 @@ have been applied. This means a single mutation can safely add a container and t | `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) | | `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) | - ## Usage Examples ### Creating a primitive diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md new file mode 100644 index 00000000..61362cdb --- /dev/null +++ b/docs/primitives/replicaset.md @@ -0,0 +1,178 @@ +# ReplicaSet Primitive + +The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It +integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and +metadata. + +ReplicaSets are rarely managed directly — operators typically use Deployments. This primitive is provided for operators +that own ReplicaSets explicitly (e.g. custom rollout controllers). + +## Capabilities + +| Capability | Detail | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` | +| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers | + +## Building a ReplicaSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" + +base := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker", + Namespace: owner.Namespace, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "worker"}, + }, + // baseline spec + }, +} + +resource, err := replicaset.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature +with no version constraints and no `When()` conditions is also always enabled: + +```go +func MyFeatureMutation(version string) replicaset.Mutation { + return replicaset.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *replicaset.Mutator) error { + // record edits here + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. + +### Boolean-gated mutations + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TracingMutation(version string, enabled bool) replicaset.Mutation { + return replicaset.Mutation{ + Name: "tracing", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *replicaset.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "jaeger-agent", + Image: "jaegertracing/jaeger-agent:1.28", + }) + 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 | Object metadata edits | Labels and annotations on the `ReplicaSet` object | +| 2 | ReplicaSetSpec edits | Replicas, min ready seconds | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation. + +## Editors + +### ReplicaSetSpecEditor + +Controls replicaset-level settings via `m.EditReplicaSetSpec`. + +Available methods: `SetReplicas`, `SetMinReadySeconds`, `Raw`. + +```go +m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetReplicas(3) + e.SetMinReadySeconds(10) + return nil +}) +``` + +Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object +passed to `NewBuilder`. + +### 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("my-service-account") + 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("app"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `ReplicaSet` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +## Convenience Methods + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureReplicas(n)` | `EditReplicaSetSpec` → `SetReplicas(n)` | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## 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 and reconciliation remains idempotent. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/examples/replicaset-primitive/README.md b/examples/replicaset-primitive/README.md new file mode 100644 index 00000000..3ccbbce1 --- /dev/null +++ b/examples/replicaset-primitive/README.md @@ -0,0 +1,33 @@ +# ReplicaSet Primitive Example + +This example demonstrates the usage of the `replicaset` primitive within the operator component framework. It shows how +to manage a Kubernetes ReplicaSet as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a ReplicaSet with basic metadata and spec. +- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the + `Mutator`. +- **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`: sidecar injection, env vars, and version-based image updates. +- `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the + `replicaset.Builder`. +- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/replicaset-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components. +4. Print the resulting status conditions. diff --git a/examples/replicaset-primitive/app/controller.go b/examples/replicaset-primitive/app/controller.go new file mode 100644 index 00000000..1b2e569a --- /dev/null +++ b/examples/replicaset-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the replicaset primitive. +package app + +import ( + "context" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewReplicaSetResource is a factory function to create the replicaset resource. + // This allows us to inject the resource construction logic. + NewReplicaSetResource 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 replicaset resource for this owner. + rsResource, err := r.NewReplicaSetResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the replicaset. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(rsResource, 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/replicaset-primitive/app/owner.go b/examples/replicaset-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/replicaset-primitive/app/owner.go @@ -0,0 +1,20 @@ +package app + +import ( + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme diff --git a/examples/replicaset-primitive/features/mutations.go b/examples/replicaset-primitive/features/mutations.go new file mode 100644 index 00000000..784394c3 --- /dev/null +++ b/examples/replicaset-primitive/features/mutations.go @@ -0,0 +1,76 @@ +// Package features provides feature plan mutations for the replicaset-primitive example. +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/replicaset" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds a Jaeger sidecar to the replicaset. +func TracingFeature(enabled bool) replicaset.Mutation { + return replicaset.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *replicaset.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "jaeger-agent", + Image: "jaegertracing/jaeger-agent:1.28", + }) + + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "JAEGER_AGENT_HOST", + Value: "localhost", + }) + + return nil + }, + } +} + +// MetricsFeature adds an exporter sidecar and some annotations. +func MetricsFeature(enabled bool, port int) replicaset.Mutation { + return replicaset.Mutation{ + Name: "Metrics", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *replicaset.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "prometheus-exporter", + Image: "prom/node-exporter:v1.3.1", + }) + + m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("prometheus.io/scrape", "true") + meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port)) + return nil + }) + + return nil + }, + } +} + +// VersionFeature sets the image version and a label. +func VersionFeature(version string) replicaset.Mutation { + return replicaset.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *replicaset.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-app:%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/replicaset-primitive/main.go b/examples/replicaset-primitive/main.go new file mode 100644 index 00000000..24efa3c1 --- /dev/null +++ b/examples/replicaset-primitive/main.go @@ -0,0 +1,122 @@ +// Package main is the entry point for the replicaset 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/replicaset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/resources" + appsv1 "k8s.io/api/apps/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 := appsv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add apps/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 replicaset resource factory. + NewReplicaSetResource: resources.NewReplicaSetResource, + } + + // 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/replicaset-primitive/resources/replicaset.go b/examples/replicaset-primitive/resources/replicaset.go new file mode 100644 index 00000000..29b9febe --- /dev/null +++ b/examples/replicaset-primitive/resources/replicaset.go @@ -0,0 +1,76 @@ +// Package resources provides resource implementations for the replicaset primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewReplicaSetResource constructs a replicaset primitive resource with all the features. +func NewReplicaSetResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base replicaset object. + base := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-replicaset", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": owner.Name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "my-app:latest", // Will be overwritten by VersionFeature + }, + }, + }, + }, + }, + } + + // 2. Initialize the replicaset builder. + builder := replicaset.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, 9090)) + + // 4. Data extraction (optional). + builder.WithDataExtractor(func(rs appsv1.ReplicaSet) error { + fmt.Printf("Reconciling replicaset: %s, ready replicas: %d\n", rs.Name, rs.Status.ReadyReplicas) + + // Print the complete replicaset resource object as yaml + y, err := yaml.Marshal(rs) + if err != nil { + return fmt.Errorf("failed to marshal replicaset to yaml: %w", err) + } + fmt.Printf("Complete ReplicaSet Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/replicasetspec.go b/pkg/mutation/editors/replicasetspec.go new file mode 100644 index 00000000..d2e2ed6f --- /dev/null +++ b/pkg/mutation/editors/replicasetspec.go @@ -0,0 +1,36 @@ +package editors + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// ReplicaSetSpecEditor provides a typed API for mutating a Kubernetes ReplicaSetSpec. +// +// Note: spec.selector is immutable after creation and is not exposed here. Set it via +// the desired object passed to the ReplicaSet builder. +type ReplicaSetSpecEditor struct { + spec *appsv1.ReplicaSetSpec +} + +// NewReplicaSetSpecEditor creates a new ReplicaSetSpecEditor for the given ReplicaSetSpec. +func NewReplicaSetSpecEditor(spec *appsv1.ReplicaSetSpec) *ReplicaSetSpecEditor { + return &ReplicaSetSpecEditor{spec: spec} +} + +// Raw returns the underlying *appsv1.ReplicaSetSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *ReplicaSetSpecEditor) Raw() *appsv1.ReplicaSetSpec { + return e.spec +} + +// SetReplicas sets the number of desired replicas for the ReplicaSet. +func (e *ReplicaSetSpecEditor) SetReplicas(replicas int32) { + e.spec.Replicas = &replicas +} + +// SetMinReadySeconds sets the minimum number of seconds for which a newly created pod should be ready +// without any of its container crashing, for it to be considered available. +func (e *ReplicaSetSpecEditor) SetMinReadySeconds(seconds int32) { + e.spec.MinReadySeconds = seconds +} diff --git a/pkg/mutation/editors/replicasetspec_test.go b/pkg/mutation/editors/replicasetspec_test.go new file mode 100644 index 00000000..30b16adb --- /dev/null +++ b/pkg/mutation/editors/replicasetspec_test.go @@ -0,0 +1,30 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" +) + +func TestReplicaSetSpecEditor(t *testing.T) { + t.Run("SetReplicas", func(t *testing.T) { + spec := &appsv1.ReplicaSetSpec{} + editor := NewReplicaSetSpecEditor(spec) + editor.SetReplicas(3) + assert.Equal(t, int32(3), *spec.Replicas) + }) + + t.Run("SetMinReadySeconds", func(t *testing.T) { + spec := &appsv1.ReplicaSetSpec{} + editor := NewReplicaSetSpecEditor(spec) + editor.SetMinReadySeconds(10) + assert.Equal(t, int32(10), spec.MinReadySeconds) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &appsv1.ReplicaSetSpec{} + editor := NewReplicaSetSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/primitives/replicaset/builder.go b/pkg/primitives/replicaset/builder.go new file mode 100644 index 00000000..49311bf1 --- /dev/null +++ b/pkg/primitives/replicaset/builder.go @@ -0,0 +1,173 @@ +// Package replicaset provides a builder and resource for managing Kubernetes ReplicaSets. +package replicaset + +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" + appsv1 "k8s.io/api/apps/v1" +) + +// Builder is a configuration helper for creating and customizing a ReplicaSet 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.WorkloadBuilder[*appsv1.ReplicaSet, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided ReplicaSet object. +// +// The ReplicaSet 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 replicaset must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(replicaset *appsv1.ReplicaSet) *Builder { + identityFunc := func(rs *appsv1.ReplicaSet) string { + return fmt.Sprintf("apps/v1/ReplicaSet/%s/%s", rs.Namespace, rs.Name) + } + + base := generic.NewWorkloadBuilder[*appsv1.ReplicaSet, *Mutator]( + replicaset, + identityFunc, + NewMutator, + ) + + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the ReplicaSet. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// They are typically used by Features to inject environment variables, +// arguments, or other configuration into the ReplicaSet's containers. +// +// Since mutations are often version-gated, the provided feature.Mutation +// should contain the logic to determine if and how the mutation is applied +// based on the component's current version or configuration. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining if the +// ReplicaSet has reached its desired state. +// +// The default behavior uses DefaultConvergingStatusHandler, which considers a +// ReplicaSet ready when its ReadyReplicas count matches the desired replica count. +// Use this method if your ReplicaSet requires more complex health checks. +// +// If you want to augment the default behavior, you can call DefaultConvergingStatusHandler +// within your custom handler. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *appsv1.ReplicaSet) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the ReplicaSet reports its health while +// it is still converging. +// +// The default behavior uses DefaultGraceStatusHandler. +// +// If you want to augment the default behavior, you can call DefaultGraceStatusHandler +// within your custom handler. +func (b *Builder) WithCustomGraceStatus( + handler func(*appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which reports the +// progress of scaling down to zero replicas. +// +// If you want to augment the default behavior, you can call DefaultSuspensionStatusHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the ReplicaSet should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which scales the +// ReplicaSet to zero replicas. +// +// If you want to augment the default behavior, you can call DefaultSuspendMutationHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the ReplicaSet when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which does not +// delete ReplicaSets during suspension (only scaled down). Return true from +// this handler if you want the ReplicaSet to be completely removed from the +// cluster when suspended. +// +// If you want to augment the default behavior, you can call DefaultDeleteOnSuspendHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*appsv1.ReplicaSet) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// ReplicaSet after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields (like names or assigned +// IPs) and making them available to other components or resources via the +// framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(appsv1.ReplicaSet) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(rs *appsv1.ReplicaSet) error { + return extractor(*rs) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base ReplicaSet object was provided. +// - The ReplicaSet 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/replicaset/builder_test.go b/pkg/primitives/replicaset/builder_test.go new file mode 100644 index 00000000..8c4c4c3e --- /dev/null +++ b/pkg/primitives/replicaset/builder_test.go @@ -0,0 +1,234 @@ +package replicaset + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/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 + replicaset *appsv1.ReplicaSet + expectedErr string + }{ + { + name: "nil replicaset", + replicaset: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + replicaset: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + replicaset: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid replicaset", + replicaset: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.replicaset).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, "apps/v1/ReplicaSet/test-ns/test-rs", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(rs). + 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("WithCustomConvergeStatus", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *appsv1.ReplicaSet) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusUpdating}, nil + } + res, err := NewBuilder(rs). + WithCustomConvergeStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.ConvergingStatusHandler) + status, err := res.base.ConvergingStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusUpdating, status.Status) + }) + + t.Run("WithCustomGraceStatus", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + } + res, err := NewBuilder(rs). + WithCustomGraceStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.GraceStatusHandler) + status, err := res.base.GraceStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.ReplicaSet) bool { + return true + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ appsv1.ReplicaSet) error { + called = true + return nil + } + res, err := NewBuilder(rs). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&appsv1.ReplicaSet{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(rs). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/replicaset/handlers.go b/pkg/primitives/replicaset/handlers.go new file mode 100644 index 00000000..1ba5a37d --- /dev/null +++ b/pkg/primitives/replicaset/handlers.go @@ -0,0 +1,121 @@ +package replicaset + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + appsv1 "k8s.io/api/apps/v1" +) + +// DefaultConvergingStatusHandler is the default logic for determining if a ReplicaSet has reached its desired state. +// +// It considers a ReplicaSet ready when the replicaset controller has observed the current generation +// (Status.ObservedGeneration >= ObjectMeta.Generation) and Status.ReadyReplicas matches the +// Spec.Replicas (defaulting to 1 if nil). If the controller has not yet observed the latest spec, +// the handler reports Creating (when the resource was just created) or Updating (otherwise) to avoid +// falsely reporting health based on stale status fields. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomConvergeStatus. It can be reused within custom handlers to augment the default behavior. +func DefaultConvergingStatusHandler( + op concepts.ConvergingOperation, rs *appsv1.ReplicaSet, +) (concepts.AliveStatusWithReason, error) { + if status := concepts.StaleGenerationStatus( + op, rs.Status.ObservedGeneration, rs.Generation, "replicaset", + ); status != nil { + return *status, nil + } + + desiredReplicas := int32(1) + if rs.Spec.Replicas != nil { + desiredReplicas = *rs.Spec.Replicas + } + + if rs.Status.ReadyReplicas == desiredReplicas { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "All replicas are ready", + }, nil + } + + var status concepts.AliveConvergingStatus + switch op { + case concepts.ConvergingOperationCreated: + status = concepts.AliveConvergingStatusCreating + case concepts.ConvergingOperationUpdated: + status = concepts.AliveConvergingStatusUpdating + default: + status = concepts.AliveConvergingStatusScaling + } + + return concepts.AliveStatusWithReason{ + Status: status, + Reason: fmt.Sprintf("Waiting for replicas: %d/%d ready", rs.Status.ReadyReplicas, desiredReplicas), + }, nil +} + +// DefaultGraceStatusHandler provides a default health assessment of the ReplicaSet when it has not yet +// reached full readiness. +// +// It categorizes the current state into: +// - GraceStatusDegraded: At least one replica is ready, but the desired count is not met. +// - GraceStatusDown: No replicas are ready. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomGraceStatus. It can be reused within custom handlers to augment the default behavior. +func DefaultGraceStatusHandler(rs *appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error) { + if rs.Status.ReadyReplicas > 0 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "ReplicaSet partially available", + }, nil + } + + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "No replicas are ready", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the ReplicaSet +// when the parent component is suspended. +// +// It always returns false, meaning the ReplicaSet is kept in the cluster but scaled to zero replicas. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendDeletionDecision. It can be reused within custom handlers. +func DefaultDeleteOnSuspendHandler(_ *appsv1.ReplicaSet) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a ReplicaSet when the component is suspended. +// +// It scales the ReplicaSet to zero replicas by setting Spec.Replicas to 0. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendMutation. It can be reused within custom handlers. +func DefaultSuspendMutationHandler(mutator *Mutator) error { + mutator.EnsureReplicas(0) + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It reports whether the ReplicaSet has successfully scaled down to zero replicas +// by checking if Status.Replicas is 0. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendStatus. It can be reused within custom handlers. +func DefaultSuspensionStatusHandler(rs *appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error) { + if rs.Status.Replicas == 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "ReplicaSet scaled to zero", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("Waiting for replicas to scale down, %d replicas still running.", rs.Status.Replicas), + }, nil +} diff --git a/pkg/primitives/replicaset/handlers_test.go b/pkg/primitives/replicaset/handlers_test.go new file mode 100644 index 00000000..1fc84d18 --- /dev/null +++ b/pkg/primitives/replicaset/handlers_test.go @@ -0,0 +1,247 @@ +package replicaset + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestDefaultConvergingStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + rs *appsv1.ReplicaSet + wantStatus concepts.AliveConvergingStatus + wantReason string + }{ + { + name: "ready with 1 replica (default)", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{}, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "ready with custom replicas", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 3, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "creating", + op: concepts.ConvergingOperationCreated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "updating", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "scaling", + op: concepts.ConvergingOperation("Scaling"), + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusScaling, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "zero replicas desired and ready", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(0)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 0, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "stale observed generation after create", + op: concepts.ConvergingOperationCreated, + rs: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.ReplicaSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for replicaset controller to observe latest spec", + }, + { + name: "stale observed generation after update", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.ReplicaSetStatus{ + ObservedGeneration: 2, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicaset controller to observe latest spec", + }, + { + name: "stale observed generation with no operation", + op: concepts.ConvergingOperationNone, + rs: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.ReplicaSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicaset controller to observe latest spec", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.rs) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultGraceStatusHandler(t *testing.T) { + t.Run("degraded (some ready)", func(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + } + got, err := DefaultGraceStatusHandler(rs) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "ReplicaSet partially available", got.Reason) + }) + + t.Run("down (none ready)", func(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 0, + }, + } + got, err := DefaultGraceStatusHandler(rs) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDown, got.Status) + assert.Equal(t, "No replicas are ready", got.Reason) + }) +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + rs := &appsv1.ReplicaSet{} + assert.False(t, DefaultDeleteOnSuspendHandler(rs)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + mutator := NewMutator(rs) + mutator.BeginFeature() + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + assert.Equal(t, int32(0), *rs.Spec.Replicas) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + rs *appsv1.ReplicaSet + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended", + rs: &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + Replicas: 0, + }, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "ReplicaSet scaled to zero", + }, + { + name: "suspending", + rs: &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + Replicas: 2, + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for replicas to scale down, 2 replicas still running.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultSuspensionStatusHandler(tt.rs) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} diff --git a/pkg/primitives/replicaset/mutator.go b/pkg/primitives/replicaset/mutator.go new file mode 100644 index 00000000..6245a2f5 --- /dev/null +++ b/pkg/primitives/replicaset/mutator.go @@ -0,0 +1,464 @@ +package replicaset + +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" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a replicaset 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 { + replicaSetMetadataEdits []func(*editors.ObjectMetaEditor) error + replicaSetSpecEdits []func(*editors.ReplicaSetSpecEditor) 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 ReplicaSet. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the ReplicaSet in a single controlled pass when Apply() is called. +// +// This approach ensures that mutations are applied consistently and minimizes +// repeated scans of the underlying Kubernetes structures. +// +// 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 *appsv1.ReplicaSet + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ReplicaSet. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the ReplicaSet. BeginFeature must be called before registering +// any mutations. +func NewMutator(current *appsv1.ReplicaSet) *Mutator { + return &Mutator{ + current: current, + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until another +// BeginFeature is called. +// +// This is used to ensure that mutations from different features are applied +// in registration order while maintaining internal category ordering within +// each feature. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditReplicaSetSpec records a mutation for the ReplicaSet's top-level spec. +// +// Planning: +// All replicaset spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, replicaset spec edits are executed AFTER replicaset-metadata edits but BEFORE pod template/spec/container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditReplicaSetSpec(edit func(*editors.ReplicaSetSpecEditor) error) { + if edit == nil { + return + } + m.active.replicaSetSpecEdits = append(m.active.replicaSetSpecEdits, edit) +} + +// EditPodSpec records a mutation for the ReplicaSet's pod spec. +// +// Planning: +// All pod spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, pod spec edits are executed AFTER replica/metadata edits but BEFORE container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { + if edit == nil { + return + } + m.active.podSpecEdits = append(m.active.podSpecEdits, edit) +} + +// EditPodTemplateMetadata records a mutation for the ReplicaSet's pod template metadata. +// +// Planning: +// All pod template metadata edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, pod template metadata edits are executed AFTER replica/replicaset-metadata edits but BEFORE pod spec/container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.podTemplateMetadataEdits = append(m.active.podTemplateMetadataEdits, edit) +} + +// EditObjectMetadata records a mutation for the ReplicaSet's own metadata. +// +// Planning: +// All object metadata edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, object metadata edits are executed BEFORE all other categories within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.replicaSetMetadataEdits = append(m.active.replicaSetMetadataEdits, edit) +} + +// EditContainers records a mutation for containers matching the given selector. +// +// Planning: +// All container edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, container edits are executed AFTER container presence operations within the same feature. +// +// Selection: +// - The selector determines which containers the edit function will be called for. +// - If either selector or edit function is nil, the registration is ignored. +// - Selectors are intended to target containers defined by the baseline resource structure or added by earlier presence operations. +// - Selector matching is evaluated against a snapshot taken after the current feature's container presence operations are applied. +// - Mutations should not rely on earlier edits in the SAME feature phase changing which selectors match. +func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.containerEdits = append(m.active.containerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EditInitContainers records a mutation for init containers matching the given selector. +// +// Planning: +// All init container edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, init container edits apply only to spec.template.spec.initContainers. +// - They run in their own category during Apply(), after init container presence operations within the same feature. +// +// Selection: +// - The selector determines which init containers the edit function will be called for. +// - If either selector or edit function is nil, the registration is ignored. +// - Selector matching is evaluated against a snapshot taken after the current feature's init container presence operations are applied. +func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureContainer records that a regular container must be present in the ReplicaSet. +// If a container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureContainer(container corev1.Container) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveContainer records that a regular container should be removed by name. +func (m *Mutator) RemoveContainer(name string) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveContainers records that multiple regular containers should be removed by name. +func (m *Mutator) RemoveContainers(names []string) { + for _, name := range names { + m.RemoveContainer(name) + } +} + +// EnsureInitContainer records that an init container must be present in the ReplicaSet. +// If an init container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveInitContainer records that an init container should be removed by name. +func (m *Mutator) RemoveInitContainer(name string) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveInitContainers records that multiple init containers should be removed by name. +func (m *Mutator) RemoveInitContainers(names []string) { + for _, name := range names { + m.RemoveInitContainer(name) + } +} + +// EnsureReplicas records the desired number of replicas for the ReplicaSet. +func (m *Mutator) EnsureReplicas(replicas int32) { + m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetReplicas(replicas) + return nil + }) +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the ReplicaSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) EnsureContainerEnvVar(ev corev1.EnvVar) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(ev) + return nil + }) +} + +// RemoveContainerEnvVar records that an environment variable should be +// removed from all containers of the ReplicaSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerEnvVar(name string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVar(name) + return nil + }) +} + +// RemoveContainerEnvVars records that multiple environment variables should be +// removed from all containers of the ReplicaSet. +// +// This is a convenience wrapper over EditContainers. +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 ReplicaSet. +// +// This is a convenience wrapper over EditContainers. +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 ReplicaSet. +// +// This is a convenience wrapper over EditContainers. +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 ReplicaSet. +// +// This is a convenience wrapper over EditContainers. +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 ReplicaSet. +// +// 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. ReplicaSetSpec edits +// 3. Pod template metadata edits +// 4. Pod spec edits +// 5. Regular container presence operations +// 6. Regular container edits +// 7. Init container presence operations +// 8. Init container edits +// +// Within each category of a single feature, edits are applied in their registration order. +// +// Selection & Identity: +// - Container selectors target containers in the state they are in at the start of that feature's +// container phase (after presence operations of the SAME feature have been applied). +// - Selector matching within a phase is evaluated against a snapshot of containers at the start +// of that phase, not the progressively mutated live containers. +// - Later features observe the ReplicaSet as modified by all previous features. +// +// Timing: +// No changes are made to the ReplicaSet until Apply() is called. +// Selectors and edit functions are executed during this pass. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.replicaSetMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.replicaSetMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. ReplicaSetSpec + if len(plan.replicaSetSpecEdits) > 0 { + editor := editors.NewReplicaSetSpecEditor(&m.current.Spec) + for _, edit := range plan.replicaSetSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. Pod template metadata + if len(plan.podTemplateMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.Spec.Template.ObjectMeta) + for _, edit := range plan.podTemplateMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 4. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec.Template.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 5. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.Template.Spec.Containers, op) + } + + // 6. Regular container edits + if len(plan.containerEdits) > 0 { + // Take snapshot of containers AFTER presence ops but BEFORE applying any edits for stable selector matching + snapshots := make([]corev1.Container, len(m.current.Spec.Template.Spec.Containers)) + for i := range m.current.Spec.Template.Spec.Containers { + m.current.Spec.Template.Spec.Containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Template.Spec.Containers { + container := &m.current.Spec.Template.Spec.Containers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.containerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + + // 7. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.Template.Spec.InitContainers, op) + } + + // 8. Init container edits + if len(plan.initContainerEdits) > 0 { + // Take snapshot of init containers AFTER presence ops but BEFORE applying any edits + snapshots := make([]corev1.Container, len(m.current.Spec.Template.Spec.InitContainers)) + for i := range m.current.Spec.Template.Spec.InitContainers { + m.current.Spec.Template.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Template.Spec.InitContainers { + container := &m.current.Spec.Template.Spec.InitContainers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.initContainerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + } + + return nil +} + +func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { + found := -1 + for i, c := range *containers { + if c.Name == op.name { + found = i + break + } + } + + if op.container == nil { + // Remove + if found != -1 { + *containers = append((*containers)[:found], (*containers)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*containers)[found] = *op.container + } else { + *containers = append(*containers, *op.container) + } +} diff --git a/pkg/primitives/replicaset/mutator_test.go b/pkg/primitives/replicaset/mutator_test.go new file mode 100644 index 00000000..ffc14849 --- /dev/null +++ b/pkg/primitives/replicaset/mutator_test.go @@ -0,0 +1,670 @@ +package replicaset + +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" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestMutator_EnvVars(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + 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(rs) + 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 := rs.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) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.BeginFeature() + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := rs.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_Replicas(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + m := NewMutator(rs) + m.BeginFeature() + m.EnsureReplicas(5) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, int32(5), *rs.Spec.Replicas) +} + +func TestNewMutator(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + assert.NotNil(t, m) + assert.Equal(t, rs, 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) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + + 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) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + m := NewMutator(rs) + + // Record mutations in the first feature plan + m.BeginFeature() + m.EnsureReplicas(3) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1" + return nil + }) + + // Start a new feature and record different mutations + m.BeginFeature() + m.EnsureReplicas(5) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].replicaSetSpecEdits, 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].replicaSetSpecEdits, 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) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + m.BeginFeature() + m.EnsureReplicas(3) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, int32(3), *rs.Spec.Replicas) +} + +func TestMutator_EditContainers(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + 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", rs.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", rs.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", rs.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", rs.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + 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", rs.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditReplicaSetSpec(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + m.BeginFeature() + m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetMinReadySeconds(10) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, int32(10), rs.Spec.MinReadySeconds) +} + +func TestMutator_EditMetadata(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"rs": "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", rs.Labels["rs"]) + assert.Equal(t, "ann", rs.Spec.Template.Annotations["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + 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_Order(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"orig": "label"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + + var order []string + + m := NewMutator(rs) + m.BeginFeature() + 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.EditReplicaSetSpec(func(_ *editors.ReplicaSetSpecEditor) error { + order = append(order, "rsspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "rsmeta") + return nil + }) + m.EnsureReplicas(3) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"rsmeta", "rsspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) + assert.Equal(t, int32(3), *rs.Spec.Replicas) +} + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.BeginFeature() + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = newImage + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, newImage, rs.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_ContainerPresence(t *testing.T) { + const newImage = "new-image" + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.BeginFeature() + // Replace + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + // Remove + m.RemoveContainer("sidecar") + // Append + m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, rs.Spec.Template.Spec.Containers, 2) + assert.Equal(t, "app", rs.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", rs.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "new-container", rs.Spec.Template.Spec.Containers[1].Name) + assert.Equal(t, newImage, rs.Spec.Template.Spec.Containers[1].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + 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) + + require.Len(t, rs.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-2", rs.Spec.Template.Spec.InitContainers[0].Name) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.BeginFeature() + + // First edit renames the container + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = appV2 + return nil + }) + + // Second edit should still match using "app" selector because of snapshot + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + // Third edit targeting "app-v2" should NOT match in this apply pass + m.EditContainers(selectors.ContainerNamed(appV2), func(e *editors.ContainerEditor) error { + e.Raw().Image = "should-not-be-set" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, appV2, rs.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", rs.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(rs) + 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) + + // It should work because presence happens before edits in Apply() + require.Len(t, rs.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "edited-image", rs.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_NilSafety(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + m := NewMutator(rs) + m.BeginFeature() + + // These should all be no-ops and not panic + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditPodTemplateMetadata(nil) + m.EditObjectMetadata(nil) + m.EditReplicaSetSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To[int32](1), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + } + + m := NewMutator(rs) + + // Feature A: sets replicas to 2, image to v2 + m.BeginFeature() + m.EnsureReplicas(2) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B: sets replicas to 3, image to v3 + m.BeginFeature() + m.EnsureReplicas(3) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + // Feature B should win + assert.Equal(t, int32(3), *rs.Spec.Replicas) + assert.Equal(t, "v3", rs.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(rs) + m.BeginFeature() + + var executionOrder []string + + // We register them in reverse order of expected execution + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + executionOrder = append(executionOrder, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + executionOrder = append(executionOrder, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "podmeta") + return nil + }) + m.EditReplicaSetSpec(func(_ *editors.ReplicaSetSpecEditor) error { + executionOrder = append(executionOrder, "replicasetspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "replicasetmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "replicasetmeta", + "replicasetspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(rs) + + // 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 - this should work! + 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", rs.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", rs.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(rs) + m.BeginFeature() + + // 1. Add init-1 + m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + + // 2. Edit init-1 (it's present in the same feature's phase) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-edited" + return nil + }) + + // 3. Rename it inside the edit phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "init-1-renamed" + return nil + }) + + // 4. Selector targeting "init-1" should still match because of snapshot in same phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-final" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, rs.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", rs.Spec.Template.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", rs.Spec.Template.Spec.InitContainers[0].Image) +} diff --git a/pkg/primitives/replicaset/resource.go b/pkg/primitives/replicaset/resource.go new file mode 100644 index 00000000..10f85d33 --- /dev/null +++ b/pkg/primitives/replicaset/resource.go @@ -0,0 +1,117 @@ +package replicaset + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes ReplicaSet 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. +// - concepts.Alive: for health and readiness tracking. +// - concepts.Suspendable: for graceful scale-down or temporary deactivation. +// - concepts.DataExtractable: for exporting information after successful reconciliation. +// +// This resource handles the lifecycle of a ReplicaSet, including initial creation, +// updates via feature mutations, and status monitoring. +type Resource struct { + base *generic.WorkloadResource[*appsv1.ReplicaSet, *Mutator] +} + +// Identity returns a unique identifier for the ReplicaSet in the format +// "apps/v1/ReplicaSet//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific ReplicaSet from other resources +// managed by the same component. +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a copy of the underlying Kubernetes ReplicaSet object. +// +// The returned object implements the client.Object interface, making it +// fully compatible with controller-runtime's Client for operations like +// Get, Create, Update, and Patch. +// +// This method is called by the framework to obtain the current state +// of the resource before applying mutations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes ReplicaSet into the desired state. +// +// The mutation process follows a specific order: +// 1. Feature Mutations: All registered feature-based mutations are applied, +// allowing for granular, version-gated changes to the ReplicaSet. +// 2. Suspension: If the resource is in a suspending state, the suspension +// logic (e.g., scaling to zero) is applied. +// +// This method is invoked by the framework during the "Update" phase of +// reconciliation. It ensures that the in-memory object reflects all +// configuration and feature requirements before it is sent to the API server. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates if the ReplicaSet has successfully reached its desired state. +// +// By default, it uses DefaultConvergingStatusHandler, which checks if the number of ReadyReplicas +// matches the desired replica count. +// +// The return value includes a descriptive status (Ready, Creating, Updating, or Scaling) +// and a human-readable reason, which are used to update the component's conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// GraceStatus provides a health assessment of the ReplicaSet when it has not yet +// reached full readiness. +// +// By default, it uses DefaultGraceStatusHandler, which categorizes the current state into: +// - GraceStatusDegraded: At least one replica is ready, but the desired count is not met. +// - GraceStatusDown: No replicas are ready. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +// DeleteOnSuspend determines whether the ReplicaSet should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false, meaning +// the ReplicaSet is kept in the cluster but scaled to zero replicas. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the ReplicaSet. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler to scale the ReplicaSet +// to zero replicas. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which reports whether the +// ReplicaSet has successfully scaled down to zero replicas or is still in the +// process of doing so. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled ReplicaSet. +// +// Data extractors are provided with a deep copy of the current ReplicaSet to +// prevent accidental mutations during the extraction process. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go new file mode 100644 index 00000000..b6750dee --- /dev/null +++ b/pkg/primitives/replicaset/resource_test.go @@ -0,0 +1,375 @@ +package replicaset + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestResource_Identity(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(rs).Build() + require.NoError(t, err) + + assert.Equal(t, "apps/v1/ReplicaSet/test-ns/test-rs", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(rs).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*appsv1.ReplicaSet) + require.True(t, ok) + assert.Equal(t, rs.Name, got.Name) + assert.Equal(t, rs.Namespace, got.Namespace) + + // Ensure it's a deep copy + got.Name = "changed" + assert.Equal(t, "test-rs", rs.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web", Image: "nginx"}, + }, + }, + }, + }, + } + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "test-mutation", + 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.(*appsv1.ReplicaSet) + + assert.Equal(t, int32(3), *got.Spec.Replicas) + assert.Equal(t, "test", got.Labels["app"]) + assert.Equal(t, "BAR", got.Spec.Template.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "v1"}, + }, + }, + }, + }, + } + + 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("app"), 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 { + // This should see image "v2" if BeginFeature() is working correctly between mutations + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + if e.Raw().Image == "v2" { + e.Raw().Image = "v3" + } + 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.(*appsv1.ReplicaSet) + + assert.Equal(t, "v3", got.Spec.Template.Spec.Containers[0].Image) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, rs *appsv1.ReplicaSet) (concepts.AliveStatusWithReason, error) { + args := m.Called(op, rs) + return args.Get(0).(concepts.AliveStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) GraceStatus(rs *appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error) { + args := m.Called(rs) + return args.Get(0).(concepts.GraceStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(rs *appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(rs) + 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(rs *appsv1.ReplicaSet) bool { + args := m.Called(rs) + return args.Bool(0) +} + +func TestResource_Status(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 2, + Replicas: 3, + }, + } + + t.Run("ConvergingStatus calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusReady := concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusHealthy} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, rs).Return(statusReady, nil) + + res, err := NewBuilder(rs). + WithCustomConvergeStatus(m.ConvergingStatus). + Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.AliveConvergingStatusHealthy, status.Status) + }) + + t.Run("ConvergingStatus uses default", func(t *testing.T) { + res, err := NewBuilder(rs).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusUpdating, status.Status) + }) + + t.Run("GraceStatus calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusReady := concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy} + m.On("GraceStatus", rs).Return(statusReady, nil) + + res, err := NewBuilder(rs). + WithCustomGraceStatus(m.GraceStatus). + Build() + require.NoError(t, err) + + status, err := res.GraceStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) + + t.Run("GraceStatus uses default", func(t *testing.T) { + res, err := NewBuilder(rs).Build() + require.NoError(t, err) + status, err := res.GraceStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, status.Status) + }) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", rs).Return(true) + + res, err := NewBuilder(rs). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(rs).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + t.Run("Suspend registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(rs).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.(*appsv1.ReplicaSet) + + assert.Equal(t, int32(0), *got.Spec.Replicas) + }) + + t.Run("Suspend uses custom mutation handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("Suspend", mock.Anything).Return(nil).Run(func(args mock.Arguments) { + mut := args.Get(0).(*Mutator) + mut.EnsureReplicas(1) + }) + + res, err := NewBuilder(rs). + 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.(*appsv1.ReplicaSet) + + m.AssertExpectations(t) + assert.Equal(t, int32(1), *got.Spec.Replicas) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: appsv1.ReplicaSetStatus{ + Replicas: 0, + }, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", rs).Return(statusSuspended, nil) + + res, err := NewBuilder(rs). + WithCustomSuspendStatus(m.SuspensionStatus). + Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(rs).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) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "web", Image: "nginx:latest"}}, + }, + }, + }, + } + + extractedImage := "" + res, err := NewBuilder(rs). + WithDataExtractor(func(r appsv1.ReplicaSet) error { + extractedImage = r.Spec.Template.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.NoError(t, err) + assert.Equal(t, "nginx:latest", extractedImage) +}