diff --git a/Makefile b/Makefile index 466f638d..054ddd2d 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/statefulset-primitive/. go run ./examples/replicaset-primitive/. go run ./examples/rolebinding-primitive/. go run ./examples/custom-resource-implementation/. diff --git a/docs/primitives.md b/docs/primitives.md index 8038b657..105ebe21 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 | +| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy | | `ReplicaSetSpecEditor` | Replicas, min ready seconds | | `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit | | `PodDisruptionBudgetSpecEditor` | MinAvailable, MaxUnavailable, selector, eviction policy | @@ -151,6 +152,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/statefulset` | Workload | [statefulset.md](primitives/statefulset.md) | | `pkg/primitives/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) | | `pkg/primitives/daemonset` | Workload | [daemonset.md](primitives/daemonset.md) | | `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md new file mode 100644 index 00000000..1ad27519 --- /dev/null +++ b/docs/primitives/statefulset.md @@ -0,0 +1,320 @@ +# StatefulSet Primitive + +The `statefulset` primitive is the framework's built-in workload abstraction for managing Kubernetes `StatefulSet` +resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, +pod specs, metadata, and volume claim templates. + +## Capabilities + +| Capability | Detail | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling`; grace handler can mark Down/Degraded | +| **Rollout health** | Surfaces stalled or failing rollouts by transitioning the resource to `Degraded` or `Down` (no grace-period timing) | +| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, statefulset spec, pod spec, containers, and volume claim templates | + +## Building a StatefulSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + +base := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db", + Namespace: owner.Namespace, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: "db-headless", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "db"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "db"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "db", Image: "postgres:15"}, + }, + }, + }, + }, +} + +resource, err := statefulset.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `StatefulSet` 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) statefulset.Mutation { + return statefulset.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *statefulset.Mutator) error { + // record edits here + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. + +### Boolean-gated mutations + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TracingMutation(version string, enabled bool) statefulset.Mutation { + return statefulset.Mutation{ + Name: "tracing", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *statefulset.Mutator) error { + m.EnsureInitContainer(corev1.Container{ + Name: "init-config", + Image: "config-init:latest", + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +Pass a `[]feature.VersionConstraint` to gate on a semver range: + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyStorageMutation(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "legacy-storage", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *statefulset.Mutator) error { + m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "STORAGE_BACKEND", Value: "legacy"}) + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded. This ensures structural consistency across mutations. + +| Step | Category | What it affects | +| ---- | -------------------------------- | ----------------------------------------------------------------------- | +| 1 | StatefulSet metadata edits | Labels and annotations on the `StatefulSet` object | +| 2 | StatefulSetSpec edits | Replicas, service name, update strategy, etc. | +| 3 | Pod template metadata edits | Labels and annotations on the pod template | +| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | +| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | +| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | +| 9 | Volume claim template operations | Adding or removing entries from `spec.volumeClaimTemplates` | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation. +This means a single mutation can add a container and then configure it without selector resolution issues. + +## Editors + +### StatefulSetSpecEditor + +Controls statefulset-level settings via `m.EditStatefulSetSpec`. + +Available methods: `SetReplicas`, `SetServiceName`, `SetPodManagementPolicy`, `SetUpdateStrategy`, +`SetRevisionHistoryLimit`, `SetMinReadySeconds`, `SetPersistentVolumeClaimRetentionPolicy`, `Raw`. + +```go +m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(3) + e.SetServiceName("db-headless") + e.SetPodManagementPolicy(appsv1.ParallelPodManagement) + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.Raw().UpdateStrategy = appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + } + return nil +}) +``` + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("db-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "db-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a +[selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"}) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("2Gi")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `StatefulSet` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +// On the StatefulSet itself +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) + +// On the pod template +m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("prometheus.io/scrape", "true") + return nil +}) +``` + +## Volume Claim Templates + +The mutator provides `EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` for managing persistent storage: + +```go +m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, +}) +``` + +**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. These mutation methods are +primarily useful for constructing the initial desired state or when recreating a StatefulSet. + +## Convenience Methods + +The `Mutator` also exposes convenience wrappers: + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureReplicas(n)` | `EditStatefulSetSpec` → `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)` | + +## Full Example: Database StatefulSet with Storage + +```go +func DatabaseMutation(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "database-storage", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *statefulset.Mutator) error { + // Configure the StatefulSet spec + m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(3) + e.SetPodManagementPolicy(appsv1.OrderedReadyPodManagement) + return nil + }) + + // Add a volume claim template for persistent data + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("50Gi"), + }, + }, + }, + }) + + // Mount the volume in the database container + m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "data", + MountPath: "/var/lib/postgresql/data", + }) + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +The internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly and reconciliation remains idempotent. + +**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. + +**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/statefulset-primitive/README.md b/examples/statefulset-primitive/README.md new file mode 100644 index 00000000..80682a0e --- /dev/null +++ b/examples/statefulset-primitive/README.md @@ -0,0 +1,37 @@ +# StatefulSet Primitive Example + +This example demonstrates the usage of the `statefulset` primitive within the operator component framework. It shows how +to manage a Kubernetes StatefulSet as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a StatefulSet with basic metadata, spec, and volume claim templates. +- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the + `Mutator`. +- **Custom Status Handlers**: Overriding the default logic for determining readiness (via `ConvergingStatus` and the + `WithCustomConvergeStatus` builder hook) and health assessment during rollouts (`GraceStatus`). +- **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. +- **Data Extraction**: Harvesting information from the reconciled resource. + +## 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. + - `handlers.go`: custom status and suspension handlers. +- `resources/`: Contains the central `NewStatefulSetResource` factory that assembles all features using the + `statefulset.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/statefulset-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/statefulset-primitive/app/controller.go b/examples/statefulset-primitive/app/controller.go new file mode 100644 index 00000000..29a7aca9 --- /dev/null +++ b/examples/statefulset-primitive/app/controller.go @@ -0,0 +1,50 @@ +// Package app provides a sample controller using the statefulset 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 + + // NewStatefulSetResource is a factory function to create the statefulset resource. + NewStatefulSetResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + stsResource, err := r.NewStatefulSetResource(owner) + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(stsResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + 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/statefulset-primitive/app/owner.go b/examples/statefulset-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/statefulset-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/statefulset-primitive/features/handlers.go b/examples/statefulset-primitive/features/handlers.go new file mode 100644 index 00000000..f3a0bdf0 --- /dev/null +++ b/examples/statefulset-primitive/features/handlers.go @@ -0,0 +1,63 @@ +// Package features provides sample features for the statefulset primitive. +package features + +import ( + "fmt" + "time" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + appsv1 "k8s.io/api/apps/v1" +) + +// CustomConvergeStatus demonstrates a custom handler for statefulset readiness. +func CustomConvergeStatus() func(concepts.ConvergingOperation, *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + return func(op concepts.ConvergingOperation, s *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + status, err := statefulset.DefaultConvergingStatusHandler(op, s) + if err != nil { + return status, err + } + + if status.Status == concepts.AliveConvergingStatusHealthy { + status.Reason = "Application is fully operational and healthy" + } else { + status.Reason = fmt.Sprintf("Application is warming up: %s", status.Reason) + } + + return status, nil + } +} + +// CustomGraceStatus demonstrates a custom handler for health when not ready. +func CustomGraceStatus() func(*appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + return func(s *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + if s.Status.ReadyReplicas < 2 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "At least 2 replicas are required for minimal service", + }, nil + } + + return statefulset.DefaultGraceStatusHandler(s) + } +} + +// CustomSuspendMutation demonstrates a custom mutation when suspended. +func CustomSuspendMutation() func(*statefulset.Mutator) error { + return func(m *statefulset.Mutator) error { + if err := statefulset.DefaultSuspendMutationHandler(m); err != nil { + return err + } + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + raw := meta.Raw() + if _, exists := raw.Annotations["example.io/suspended-at"]; !exists { + meta.EnsureAnnotation("example.io/suspended-at", time.Now().UTC().Format(time.RFC3339)) + } + return nil + }) + + return nil + } +} diff --git a/examples/statefulset-primitive/features/mutations.go b/examples/statefulset-primitive/features/mutations.go new file mode 100644 index 00000000..d324f826 --- /dev/null +++ b/examples/statefulset-primitive/features/mutations.go @@ -0,0 +1,75 @@ +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/statefulset" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds a Jaeger sidecar to the statefulset. +func TracingFeature(enabled bool) statefulset.Mutation { + return statefulset.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *statefulset.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) statefulset.Mutation { + return statefulset.Mutation{ + Name: "Metrics", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *statefulset.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) statefulset.Mutation { + return statefulset.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *statefulset.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/statefulset-primitive/main.go b/examples/statefulset-primitive/main.go new file mode 100644 index 00000000..1f1dce92 --- /dev/null +++ b/examples/statefulset-primitive/main.go @@ -0,0 +1,121 @@ +// Package main is the entry point for the statefulset 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/statefulset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/statefulset-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, + }, + + NewStatefulSetResource: resources.NewStatefulSetResource, + } + + // 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/statefulset-primitive/resources/statefulset.go b/examples/statefulset-primitive/resources/statefulset.go new file mode 100644 index 00000000..bb7b0268 --- /dev/null +++ b/examples/statefulset-primitive/resources/statefulset.go @@ -0,0 +1,97 @@ +// Package resources provides resource implementations for the statefulset primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewStatefulSetResource constructs a statefulset primitive resource with all the features. +func NewStatefulSetResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base statefulset object. + base := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-statefulset", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: owner.Name + "-headless", + 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 + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + }, + }, + }, + } + + // 2. Initialize the statefulset builder. + builder := statefulset.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. Configure custom status handlers. + builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) + builder.WithCustomGraceStatus(features.CustomGraceStatus()) + + // 5. Configure custom suspension logic. + builder.WithCustomSuspendMutation(features.CustomSuspendMutation()) + + // 6. Data extraction (optional). + builder.WithDataExtractor(func(s appsv1.StatefulSet) error { + fmt.Printf("Reconciling statefulset: %s, ready replicas: %d\n", s.Name, s.Status.ReadyReplicas) + + y, err := yaml.Marshal(s) + if err != nil { + return fmt.Errorf("failed to marshal statefulset to yaml: %w", err) + } + fmt.Printf("Complete StatefulSet Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 7. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/statefulsetspec.go b/pkg/mutation/editors/statefulsetspec.go new file mode 100644 index 00000000..be53a4b8 --- /dev/null +++ b/pkg/mutation/editors/statefulsetspec.go @@ -0,0 +1,61 @@ +package editors + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// StatefulSetSpecEditor provides a typed API for mutating a Kubernetes StatefulSetSpec. +type StatefulSetSpecEditor struct { + spec *appsv1.StatefulSetSpec +} + +// NewStatefulSetSpecEditor creates a new StatefulSetSpecEditor for the given StatefulSetSpec. +func NewStatefulSetSpecEditor(spec *appsv1.StatefulSetSpec) *StatefulSetSpecEditor { + return &StatefulSetSpecEditor{spec: spec} +} + +// Raw returns the underlying *appsv1.StatefulSetSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *StatefulSetSpecEditor) Raw() *appsv1.StatefulSetSpec { + return e.spec +} + +// SetReplicas sets the number of desired replicas for the StatefulSet. +func (e *StatefulSetSpecEditor) SetReplicas(replicas int32) { + e.spec.Replicas = &replicas +} + +// SetServiceName sets the name of the governing Service for the StatefulSet. +func (e *StatefulSetSpecEditor) SetServiceName(name string) { + e.spec.ServiceName = name +} + +// SetPodManagementPolicy sets the policy for creating pods under the StatefulSet. +func (e *StatefulSetSpecEditor) SetPodManagementPolicy(policy appsv1.PodManagementPolicyType) { + e.spec.PodManagementPolicy = policy +} + +// SetUpdateStrategy sets the update strategy for the StatefulSet. +func (e *StatefulSetSpecEditor) SetUpdateStrategy(strategy appsv1.StatefulSetUpdateStrategy) { + e.spec.UpdateStrategy = strategy +} + +// SetRevisionHistoryLimit sets the number of revisions to retain in the StatefulSet's revision history. +func (e *StatefulSetSpecEditor) SetRevisionHistoryLimit(limit int32) { + e.spec.RevisionHistoryLimit = &limit +} + +// SetMinReadySeconds sets the minimum number of seconds for which a newly created pod should be ready +// before it is considered available. +func (e *StatefulSetSpecEditor) SetMinReadySeconds(seconds int32) { + e.spec.MinReadySeconds = seconds +} + +// SetPersistentVolumeClaimRetentionPolicy sets the policy for retaining PVCs +// when pods are scaled down or the StatefulSet is deleted. +func (e *StatefulSetSpecEditor) SetPersistentVolumeClaimRetentionPolicy( + policy *appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy, +) { + e.spec.PersistentVolumeClaimRetentionPolicy = policy +} diff --git a/pkg/mutation/editors/statefulsetspec_test.go b/pkg/mutation/editors/statefulsetspec_test.go new file mode 100644 index 00000000..f2e27e99 --- /dev/null +++ b/pkg/mutation/editors/statefulsetspec_test.go @@ -0,0 +1,72 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" +) + +func TestStatefulSetSpecEditor(t *testing.T) { + t.Run("SetReplicas", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetReplicas(3) + assert.Equal(t, int32(3), *spec.Replicas) + }) + + t.Run("SetServiceName", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetServiceName("my-service") + assert.Equal(t, "my-service", spec.ServiceName) + }) + + t.Run("SetPodManagementPolicy", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetPodManagementPolicy(appsv1.ParallelPodManagement) + assert.Equal(t, appsv1.ParallelPodManagement, spec.PodManagementPolicy) + }) + + t.Run("SetUpdateStrategy", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + strategy := appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + } + editor.SetUpdateStrategy(strategy) + assert.Equal(t, appsv1.OnDeleteStatefulSetStrategyType, spec.UpdateStrategy.Type) + }) + + t.Run("SetRevisionHistoryLimit", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetRevisionHistoryLimit(5) + assert.Equal(t, int32(5), *spec.RevisionHistoryLimit) + }) + + t.Run("SetMinReadySeconds", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetMinReadySeconds(10) + assert.Equal(t, int32(10), spec.MinReadySeconds) + }) + + t.Run("SetPersistentVolumeClaimRetentionPolicy", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + policy := &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + } + editor.SetPersistentVolumeClaimRetentionPolicy(policy) + assert.Equal(t, policy, spec.PersistentVolumeClaimRetentionPolicy) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/primitives/statefulset/builder.go b/pkg/primitives/statefulset/builder.go new file mode 100644 index 00000000..6e7cd829 --- /dev/null +++ b/pkg/primitives/statefulset/builder.go @@ -0,0 +1,150 @@ +// Package statefulset provides a builder and resource for managing Kubernetes StatefulSets. +package statefulset + +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 StatefulSet 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.StatefulSet, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided StatefulSet object. +// +// The StatefulSet 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 StatefulSet must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(statefulset *appsv1.StatefulSet) *Builder { + identityFunc := func(s *appsv1.StatefulSet) string { + return fmt.Sprintf("apps/v1/StatefulSet/%s/%s", s.Namespace, s.Name) + } + + base := generic.NewWorkloadBuilder[*appsv1.StatefulSet, *Mutator]( + statefulset, + 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 StatefulSet. +// +// 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 StatefulSet's containers. +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 +// StatefulSet has reached its desired state. +// +// The default behavior uses DefaultConvergingStatusHandler, which considers a +// StatefulSet ready when its ReadyReplicas count matches the desired replica count. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the StatefulSet reports its health while +// it is still converging. +// +// The default behavior uses DefaultGraceStatusHandler. +func (b *Builder) WithCustomGraceStatus( + handler func(*appsv1.StatefulSet) (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. +func (b *Builder) WithCustomSuspendStatus( + handler func(*appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the StatefulSet should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which scales the +// StatefulSet to zero replicas. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the StatefulSet when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which does not +// delete StatefulSets during suspension (only scaled down). +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*appsv1.StatefulSet) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// StatefulSet after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields and making them available +// to other components or resources via the framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(appsv1.StatefulSet) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(s *appsv1.StatefulSet) error { + return extractor(*s) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base StatefulSet object was provided. +// - The StatefulSet 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/statefulset/builder_test.go b/pkg/primitives/statefulset/builder_test.go new file mode 100644 index 00000000..b3c96137 --- /dev/null +++ b/pkg/primitives/statefulset/builder_test.go @@ -0,0 +1,235 @@ +package statefulset + +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 + statefulset *appsv1.StatefulSet + expectedErr string + }{ + { + name: "nil statefulset", + statefulset: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + statefulset: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + statefulset: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid statefulset", + statefulset: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.statefulset).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/StatefulSet/test-ns/test-sts", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + Mutate: func(_ *Mutator) error { return nil }, + } + res, err := NewBuilder(sts). + 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() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusUpdating}, nil + } + res, err := NewBuilder(sts). + 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() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + } + res, err := NewBuilder(sts). + 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() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(sts). + 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() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(sts). + 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() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.StatefulSet) bool { + return true + } + res, err := NewBuilder(sts). + 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() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ appsv1.StatefulSet) error { + called = true + return nil + } + res, err := NewBuilder(sts). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&appsv1.StatefulSet{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(sts). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/statefulset/handlers.go b/pkg/primitives/statefulset/handlers.go new file mode 100644 index 00000000..0e59a754 --- /dev/null +++ b/pkg/primitives/statefulset/handlers.go @@ -0,0 +1,121 @@ +package statefulset + +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 StatefulSet has reached its desired state. +// +// It considers a StatefulSet ready when the statefulset 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, sts *appsv1.StatefulSet, +) (concepts.AliveStatusWithReason, error) { + if status := concepts.StaleGenerationStatus( + op, sts.Status.ObservedGeneration, sts.Generation, "statefulset", + ); status != nil { + return *status, nil + } + + desiredReplicas := int32(1) + if sts.Spec.Replicas != nil { + desiredReplicas = *sts.Spec.Replicas + } + + if sts.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", sts.Status.ReadyReplicas, desiredReplicas), + }, nil +} + +// DefaultGraceStatusHandler provides a default health assessment of the StatefulSet 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(sts *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + if sts.Status.ReadyReplicas > 0 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "StatefulSet 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 StatefulSet +// when the parent component is suspended. +// +// It always returns false, meaning the StatefulSet 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.StatefulSet) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a StatefulSet when the component is suspended. +// +// It scales the StatefulSet 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 StatefulSet 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(sts *appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error) { + if sts.Status.Replicas == 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "StatefulSet scaled to zero", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("Waiting for replicas to scale down, %d replicas still running.", sts.Status.Replicas), + }, nil +} diff --git a/pkg/primitives/statefulset/handlers_test.go b/pkg/primitives/statefulset/handlers_test.go new file mode 100644 index 00000000..f61029b7 --- /dev/null +++ b/pkg/primitives/statefulset/handlers_test.go @@ -0,0 +1,233 @@ +package statefulset + +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 + sts *appsv1.StatefulSet + wantStatus concepts.AliveConvergingStatus + wantReason string + }{ + { + name: "ready with 1 replica (default)", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{}, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "ready with custom replicas", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 3, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "creating", + op: concepts.ConvergingOperationCreated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "updating", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "scaling", + op: concepts.ConvergingOperation("Scaling"), + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusScaling, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "stale observed generation after create", + op: concepts.ConvergingOperationCreated, + sts: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for statefulset controller to observe latest spec", + }, + { + name: "stale observed generation after update", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 2, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for statefulset controller to observe latest spec", + }, + { + name: "stale observed generation with no operation", + op: concepts.ConvergingOperationNone, + sts: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for statefulset controller to observe latest spec", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.sts) + 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) { + sts := &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + } + got, err := DefaultGraceStatusHandler(sts) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "StatefulSet partially available", got.Reason) + }) + + t.Run("down (none ready)", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 0, + }, + } + got, err := DefaultGraceStatusHandler(sts) + 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) { + sts := &appsv1.StatefulSet{} + assert.False(t, DefaultDeleteOnSuspendHandler(sts)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + mutator := NewMutator(sts) + mutator.BeginFeature() + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + assert.Equal(t, int32(0), *sts.Spec.Replicas) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + sts *appsv1.StatefulSet + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended", + sts: &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + Replicas: 0, + }, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "StatefulSet scaled to zero", + }, + { + name: "suspending", + sts: &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + 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.sts) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go new file mode 100644 index 00000000..76eed7ed --- /dev/null +++ b/pkg/primitives/statefulset/mutator.go @@ -0,0 +1,514 @@ +package statefulset + +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 statefulset 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 volumeClaimTemplateOp struct { + name string + pvc *corev1.PersistentVolumeClaim // nil for remove +} + +type featurePlan struct { + statefulsetMetadataEdits []func(*editors.ObjectMetaEditor) error + statefulsetSpecEdits []func(*editors.StatefulSetSpecEditor) error + podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit + volumeClaimTemplateOps []volumeClaimTemplateOp +} + +// Mutator is a high-level helper for modifying a Kubernetes StatefulSet. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the StatefulSet in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +type Mutator struct { + current *appsv1.StatefulSet + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given StatefulSet. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the StatefulSet. BeginFeature must be called before registering +// any mutations. +func NewMutator(current *appsv1.StatefulSet) *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. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// 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 StatefulSet. +// 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 StatefulSet. +// 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) + } +} + +// EditStatefulSetSpec records a mutation for the StatefulSet's top-level spec. +// +// Planning: +// All statefulset spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, statefulset spec edits are executed AFTER statefulset-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) EditStatefulSetSpec(edit func(*editors.StatefulSetSpecEditor) error) { + if edit == nil { + return + } + m.active.statefulsetSpecEdits = append(m.active.statefulsetSpecEdits, edit) +} + +// EditPodSpec records a mutation for the StatefulSet'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 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 StatefulSet'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 statefulset spec 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 StatefulSet'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.statefulsetMetadataEdits = append(m.active.statefulsetMetadataEdits, edit) +} + +// EnsureReplicas records the desired number of replicas for the StatefulSet. +func (m *Mutator) EnsureReplicas(replicas int32) { + m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(replicas) + return nil + }) +} + +// EnsureVolumeClaimTemplate records that a PersistentVolumeClaim template must be +// present in the StatefulSet. If a VolumeClaimTemplate with the same name exists, +// it is replaced; otherwise, it is appended. +// +// Note: VolumeClaimTemplates are immutable once the StatefulSet exists in the cluster. +// During Apply, these operations are silently skipped for existing resources +// (identified by a non-empty ResourceVersion) because the Kubernetes API server +// would reject such changes. This method is primarily useful for initial creation +// or when recreating a StatefulSet. +func (m *Mutator) EnsureVolumeClaimTemplate(pvc corev1.PersistentVolumeClaim) { + m.active.volumeClaimTemplateOps = append(m.active.volumeClaimTemplateOps, volumeClaimTemplateOp{ + name: pvc.Name, + pvc: &pvc, + }) +} + +// RemoveVolumeClaimTemplate records that a VolumeClaimTemplate should be removed by name. +// +// Note: VolumeClaimTemplates are immutable once the StatefulSet exists in the cluster. +// During Apply, these operations are silently skipped for existing resources +// (identified by a non-empty ResourceVersion). This method is primarily useful +// when constructing the initial desired state. +func (m *Mutator) RemoveVolumeClaimTemplate(name string) { + m.active.volumeClaimTemplateOps = append(m.active.volumeClaimTemplateOps, volumeClaimTemplateOp{ + name: name, + pvc: nil, + }) +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the StatefulSet. +// +// 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 StatefulSet. +// +// 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 StatefulSet. +// +// 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 StatefulSet. +// +// 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 StatefulSet. +// +// 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 StatefulSet. +// +// 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 StatefulSet. +// +// 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. StatefulSetSpec 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 +// 9. Volume claim template operations +// +// Within each category of a single feature, edits are applied in their registration order. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.statefulsetMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.statefulsetMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. StatefulSetSpec + if len(plan.statefulsetSpecEdits) > 0 { + editor := editors.NewStatefulSetSpecEditor(&m.current.Spec) + for _, edit := range plan.statefulsetSpecEdits { + 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 { + 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 { + 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 + } + } + } + } + } + + // 9. Volume claim template operations + // VolumeClaimTemplates are immutable after creation. Only apply these + // operations when the StatefulSet does not yet exist on the server. + if m.current.ResourceVersion == "" { + for _, op := range plan.volumeClaimTemplateOps { + applyVolumeClaimTemplateOp(&m.current.Spec.VolumeClaimTemplates, op) + } + } + } + + 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) + } +} + +func applyVolumeClaimTemplateOp(vcts *[]corev1.PersistentVolumeClaim, op volumeClaimTemplateOp) { + found := -1 + for i, v := range *vcts { + if v.Name == op.name { + found = i + break + } + } + + if op.pvc == nil { + // Remove + if found != -1 { + *vcts = append((*vcts)[:found], (*vcts)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*vcts)[found] = *op.pvc + } else { + *vcts = append(*vcts, *op.pvc) + } +} diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go new file mode 100644 index 00000000..764347b2 --- /dev/null +++ b/pkg/primitives/statefulset/mutator_test.go @@ -0,0 +1,803 @@ +package statefulset + +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" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestMutator_EnvVars(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + 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(sts) + 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 := sts.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) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.BeginFeature() + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := sts.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) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + m := NewMutator(sts) + m.BeginFeature() + m.EnsureReplicas(5) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, int32(5), *sts.Spec.Replicas) +} + +func TestNewMutator(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + assert.NotNil(t, m) + assert.Equal(t, sts, 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) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + + 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) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + m := NewMutator(sts) + + // 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 a different mutation + m.BeginFeature() + m.EnsureReplicas(5) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].statefulsetSpecEdits, 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].statefulsetSpecEdits, 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) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.BeginFeature() + m.EnsureReplicas(3) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1) +} + +func TestMutator_EditContainers(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + 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", sts.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", sts.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", sts.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", sts.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + 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", sts.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditStatefulSetSpec(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.BeginFeature() + m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetServiceName("my-service") + e.SetMinReadySeconds(10) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "my-service", sts.Spec.ServiceName) + assert.Equal(t, int32(10), sts.Spec.MinReadySeconds) +} + +func TestMutator_EditMetadata(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"sts": "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", sts.Labels["sts"]) + assert.Equal(t, "ann", sts.Spec.Template.Annotations["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + 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) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + 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(sts) + 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.EditStatefulSetSpec(func(_ *editors.StatefulSetSpecEditor) error { + order = append(order, "stsspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "stsmeta") + return nil + }) + m.EnsureReplicas(3) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"stsmeta", "stsspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) + assert.Equal(t, int32(3), *sts.Spec.Replicas) +} + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + 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, sts.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_ContainerPresence(t *testing.T) { + const newImage = "new-image" + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.BeginFeature() + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + m.RemoveContainer("sidecar") + m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, sts.Spec.Template.Spec.Containers, 2) + assert.Equal(t, "app", sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", sts.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "new-container", sts.Spec.Template.Spec.Containers[1].Name) + assert.Equal(t, newImage, sts.Spec.Template.Spec.Containers[1].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + 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, sts.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-2", sts.Spec.Template.Spec.InitContainers[0].Name) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.BeginFeature() + + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = appV2 + return nil + }) + + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + 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, sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(sts) + m.BeginFeature() + + m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "edited-image" + return nil + }) + + m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, sts.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "edited-image", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_NilSafety(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + m := NewMutator(sts) + m.BeginFeature() + + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditInitContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditInitContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditPodTemplateMetadata(nil) + m.EditObjectMetadata(nil) + m.EditStatefulSetSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To[int32](1), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + } + + m := NewMutator(sts) + + m.BeginFeature() + m.EnsureReplicas(2) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + 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) + + assert.Equal(t, int32(3), *sts.Spec.Replicas) + assert.Equal(t, "v3", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(sts) + m.BeginFeature() + + var executionOrder []string + + 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.EditStatefulSetSpec(func(_ *editors.StatefulSetSpecEditor) error { + executionOrder = append(executionOrder, "statefulsetspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "statefulsetmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "statefulsetmeta", + "statefulsetspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(sts) + + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + 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", sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(sts) + m.BeginFeature() + + m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-edited" + return nil + }) + + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "init-1-renamed" + return nil + }) + + 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, sts.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", sts.Spec.Template.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", sts.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_VolumeClaimTemplates(t *testing.T) { + t.Run("ensure adds new VCT", func(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.BeginFeature() + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + }) + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "data", sts.Spec.VolumeClaimTemplates[0].Name) + }) + + t.Run("ensure replaces existing VCT", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + }, + }, + } + m := NewMutator(sts) + m.BeginFeature() + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("20Gi"), + }, + }, + }, + }) + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + qty := sts.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] + assert.Equal(t, "20Gi", qty.String()) + }) + + t.Run("remove VCT", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "logs"}}, + }, + }, + } + m := NewMutator(sts) + m.BeginFeature() + + m.RemoveVolumeClaimTemplate("data") + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "logs", sts.Spec.VolumeClaimTemplates[0].Name) + }) + + t.Run("remove nonexistent VCT is no-op", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + }, + }, + } + m := NewMutator(sts) + m.BeginFeature() + + m.RemoveVolumeClaimTemplate("nonexistent") + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + }) + + t.Run("VCT ops are skipped on existing StatefulSet", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "12345", + }, + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + }, + }, + } + m := NewMutator(sts) + m.BeginFeature() + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "new-volume"}, + }) + m.RemoveVolumeClaimTemplate("data") + + err := m.Apply() + require.NoError(t, err) + + // VCT ops should be skipped since the StatefulSet already exists + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "data", sts.Spec.VolumeClaimTemplates[0].Name) + }) + + t.Run("VCT ops run after container edits", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + var order []string + m := NewMutator(sts) + m.BeginFeature() + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + }) + + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + order = append(order, "container") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + // Container edits run first (step 6), then VCT ops (step 9) + assert.Equal(t, []string{"container"}, order) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + }) +} diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go new file mode 100644 index 00000000..f5f507f6 --- /dev/null +++ b/pkg/primitives/statefulset/resource.go @@ -0,0 +1,120 @@ +package statefulset + +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 StatefulSet within a controller's +// reconciliation loop. +// +// It implements several component interfaces to integrate with the operator-component-framework: +// - component.Resource: for basic identity and mutation behavior. +// - component.Alive: for health and readiness tracking. +// - component.Suspendable: for graceful scale-down or temporary deactivation. +// - component.DataExtractable: for exporting information after successful reconciliation. +// +// This resource handles the lifecycle of a StatefulSet, including initial creation, +// updates via feature mutations, and status monitoring. +type Resource struct { + base *generic.WorkloadResource[*appsv1.StatefulSet, *Mutator] +} + +// Identity returns a unique identifier for the StatefulSet in the format +// "apps/v1/StatefulSet//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific StatefulSet 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 StatefulSet 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 StatefulSet into the desired state. +// +// The mutation process follows a specific order: +// 1. Core State: The desired base state is applied to the current object. +// 2. Feature Mutations: All registered feature-based mutations are applied, +// allowing for granular, version-gated changes to the StatefulSet. +// 3. 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 StatefulSet 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 (Healthy, 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 StatefulSet 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 StatefulSet should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false, meaning +// the StatefulSet 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 StatefulSet. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler to scale the StatefulSet +// to zero replicas, which effectively stops the application while keeping the +// Kubernetes resource intact. +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 +// StatefulSet 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 StatefulSet. +// +// This is called by the framework after a successful reconciliation of the +// resource. It allows the component to export details that might be needed by +// other resources or higher-level controllers. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/statefulset/resource_test.go b/pkg/primitives/statefulset/resource_test.go new file mode 100644 index 00000000..7f64c92a --- /dev/null +++ b/pkg/primitives/statefulset/resource_test.go @@ -0,0 +1,373 @@ +package statefulset + +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) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + + assert.Equal(t, "apps/v1/StatefulSet/test-ns/test-sts", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*appsv1.StatefulSet) + require.True(t, ok) + assert.Equal(t, sts.Name, got.Name) + assert.Equal(t, sts.Namespace, got.Namespace) + + // Ensure it's a deep copy + got.Name = "changed" + assert.Equal(t, "test-sts", sts.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + ServiceName: "test-svc", + 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.StatefulSet) + 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.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + 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 { + 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.StatefulSet) + assert.Equal(t, "v3", got.Spec.Template.Spec.Containers[0].Image) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, s *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + args := m.Called(op, s) + return args.Get(0).(concepts.AliveStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) GraceStatus(s *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + args := m.Called(s) + return args.Get(0).(concepts.GraceStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(s *appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(s) + 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(s *appsv1.StatefulSet) bool { + args := m.Called(s) + return args.Bool(0) +} + +func TestResource_Status(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + 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, sts).Return(statusReady, nil) + + res, err := NewBuilder(sts). + 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(sts).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", sts).Return(statusReady, nil) + + res, err := NewBuilder(sts). + 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(sts).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) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", sts).Return(true) + + res, err := NewBuilder(sts). + 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(sts).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + t.Run("Suspend registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := sts.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, int32(0), *current.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(sts). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := sts.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + m.AssertExpectations(t) + assert.Equal(t, int32(1), *current.Spec.Replicas) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: appsv1.StatefulSetStatus{ + Replicas: 0, + }, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", sts).Return(statusSuspended, nil) + + res, err := NewBuilder(sts). + 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(sts).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) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "web", Image: "nginx:latest"}}, + }, + }, + }, + } + + extractedImage := "" + res, err := NewBuilder(sts). + WithDataExtractor(func(s appsv1.StatefulSet) error { + extractedImage = s.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) +}