diff --git a/docs/primitives.md b/docs/primitives.md index fec3bb0b..a90f97ea 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -156,6 +156,7 @@ have been applied. This means a single mutation can safely add a container and t | `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/pod` | Workload | [pod.md](primitives/pod.md) | | `pkg/primitives/job` | Task | [job.md](primitives/job.md) | | `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md new file mode 100644 index 00000000..45985592 --- /dev/null +++ b/docs/primitives/pod.md @@ -0,0 +1,226 @@ +# Pod Primitive + +The `pod` primitive is the framework's built-in workload abstraction for managing Kubernetes `Pod` resources directly. +It integrates fully with the component lifecycle and provides a mutation API for managing containers, pod specs, and +metadata. + +Pods are rarely managed directly by operators; this primitive is provided for completeness and for operators that manage +pod objects (e.g. debugging utilities, node-local agents). + +## Capabilities + +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------- | +| **Health tracking** | Monitors pod phase and container statuses; reports `Healthy`, `Creating`, `Updating`, or `Failing` | +| **Graceful rollouts** | Detects degraded or down states via grace status handler | +| **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, pod spec, and containers | + +## Building a Pod Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" + +base := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "debug-pod", + Namespace: owner.Namespace, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "debug", + Image: "busybox:latest", + }, + }, + }, +} + +resource, err := pod.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Pod` 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) pod.Mutation { + return pod.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *pod.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 DebugMutation(version string, enabled bool) pod.Mutation { + return pod.Mutation{ + Name: "debug-mode", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *pod.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "DEBUG", Value: "true"}) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded. This ensures structural consistency across mutations. + +| Step | Category | What it affects | +| ---- | -------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `Pod` object | +| 2 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 3 | Regular container presence | Adding or removing containers from `spec.containers` | +| 4 | Regular container edits | Env vars, args, resources (snapshot taken after step 3) | +| 5 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 6 | Init container edits | Env vars, args, resources (snapshot taken after step 5) | + +Container edits (steps 4 and 6) 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. + +**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall +structure of `spec.containers` and `spec.initContainers` and the majority of per-container fields (such as `env`, +`args`, resources, ports, and probes). Presence operations such as `EnsureContainer` / `RemoveContainer` (and the +corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, +not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the +Kubernetes API server will reject the update. In practice, the set of fields that can be updated in-place on an existing +Pod is very small (primarily container images, plus a few feature-gated fields such as resources with in-place resize); +treat Pods as effectively immutable and use delete-and-recreate when you need to change other container attributes. + +## Editors + +### 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`. `RemoveTolerations` accepts a predicate +function (`match func(corev1.Toleration) bool`) and removes all tolerations for which `match` returns `true`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("my-service-account") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "app-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("app"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.EnsureArg("--metrics-port=9090") + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +For fields not covered by the typed API (such as volume mounts), use `Raw()`: + +```go +m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "config", + MountPath: "/etc/config", + }) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +### Raw Escape Hatch + +All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is +insufficient. The mutation remains scoped to the editor's target — you cannot accidentally modify unrelated parts of the +spec. + +```go +m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().SecurityContext = &corev1.SecurityContext{ + ReadOnlyRootFilesystem: ptr.To(true), + } + return nil +}) +``` + +## Convenience Methods + +The `Mutator` also exposes convenience wrappers that target all containers at once: + +| Method | Equivalent to | +| ----------------------------- | ------------------------------------------------------------- | +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Suspension + +Pods cannot be paused. The default behavior deletes the pod when the component is suspended. + +- `DefaultDeleteOnSuspendHandler`: returns `true` — pod is deleted on suspend. +- `DefaultSuspendMutationHandler`: no-op (deletion is handled by the framework). +- `DefaultSuspensionStatusHandler`: always returns `{Suspended, "Pod deleted on suspend"}`. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +The internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly and reconciliation remains idempotent. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. diff --git a/examples/pod-primitive/README.md b/examples/pod-primitive/README.md new file mode 100644 index 00000000..fee67a4a --- /dev/null +++ b/examples/pod-primitive/README.md @@ -0,0 +1,32 @@ +# Pod Primitive Example + +This example demonstrates the usage of the `pod` primitive within the operator component framework. It shows how to +manage a Kubernetes Pod as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a Pod with basic metadata and spec. +- **Feature Mutations**: Applying version-gated or conditional metadata changes (labels) using the `Mutator`. +- **Suspension**: Deleting the pod when the component is suspended (pods cannot be paused). +- **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`: label mutations applied to the Pod using the `Mutator`. +- `resources/`: Contains the central `NewPodResource` factory that assembles all features using the `pod.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/pod-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components through multiple spec changes. +4. Print the resulting status conditions. diff --git a/examples/pod-primitive/app/controller.go b/examples/pod-primitive/app/controller.go new file mode 100644 index 00000000..3a69a2f5 --- /dev/null +++ b/examples/pod-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the pod 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 + + // NewPodResource is a factory function to create the pod resource. + // This allows us to inject the resource construction logic. + NewPodResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + // 1. Build the pod resource for this owner. + podResource, err := r.NewPodResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the pod. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(podResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/pod-primitive/app/owner.go b/examples/pod-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/pod-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/pod-primitive/features/mutations.go b/examples/pod-primitive/features/mutations.go new file mode 100644 index 00000000..aaebad6a --- /dev/null +++ b/examples/pod-primitive/features/mutations.go @@ -0,0 +1,47 @@ +// Package features provides sample mutations for the pod primitive example. +// +// These examples only mutate object metadata because most Pod spec fields +// are immutable after creation. Kubernetes does allow a small set of in-place +// updates (notably container images), but other fields such as env, args, and +// resources require the Pod to be deleted and recreated. +package features + +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/primitives/pod" +) + +// TracingFeature marks the pod as tracing-enabled via metadata. +// Controllers or sidecar injectors can watch the label and handle +// any required Pod replacement or injection. +func TracingFeature(enabled bool) pod.Mutation { + return pod.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *pod.Mutator) error { + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("sidecar.jaegertracing.io/inject", "true") + return nil + }) + return nil + }, + } +} + +// VersionFeature records the desired version on the pod as a label. +// It avoids mutating container images directly; while Kubernetes allows +// in-place image updates, this example keeps mutations in metadata only. +func VersionFeature(version string) pod.Mutation { + return pod.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *pod.Mutator) error { + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} diff --git a/examples/pod-primitive/main.go b/examples/pod-primitive/main.go new file mode 100644 index 00000000..350124a7 --- /dev/null +++ b/examples/pod-primitive/main.go @@ -0,0 +1,118 @@ +// Package main is the entry point for the pod 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/pod-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/resources" + corev1 "k8s.io/api/core/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 := corev1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add core/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: false, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + // Pass the pod resource factory. + NewPodResource: resources.NewPodResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/pod-primitive/resources/pod.go b/examples/pod-primitive/resources/pod.go new file mode 100644 index 00000000..45d1cb6d --- /dev/null +++ b/examples/pod-primitive/resources/pod.go @@ -0,0 +1,60 @@ +// Package resources provides resource implementations for the pod primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewPodResource constructs a pod primitive resource with all the features. +func NewPodResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base pod object. + base := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-pod", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "my-app:latest", // Base image for the app container + }, + }, + }, + } + + // 2. Initialize the pod builder. + builder := pod.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)) + + // 4. Data extraction (optional). + builder.WithDataExtractor(func(p corev1.Pod) error { + fmt.Printf("Reconciling pod: %s, phase: %s\n", p.Name, p.Status.Phase) + + // Print the complete pod resource object as yaml + y, err := yaml.Marshal(p) + if err != nil { + return fmt.Errorf("failed to marshal pod to yaml: %w", err) + } + fmt.Printf("Complete Pod Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/primitives/pod/builder.go b/pkg/primitives/pod/builder.go new file mode 100644 index 00000000..9b32239d --- /dev/null +++ b/pkg/primitives/pod/builder.go @@ -0,0 +1,172 @@ +// Package pod provides a builder and resource for managing Kubernetes Pods. +package pod + +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" + corev1 "k8s.io/api/core/v1" +) + +// Builder is a configuration helper for creating and customizing a Pod 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[*corev1.Pod, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Pod object. +// +// The Pod 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 pod must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(pod *corev1.Pod) *Builder { + identityFunc := func(p *corev1.Pod) string { + return fmt.Sprintf("v1/Pod/%s/%s", p.Namespace, p.Name) + } + + base := generic.NewWorkloadBuilder[*corev1.Pod, *Mutator]( + pod, + 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 Pod. +// +// 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 Pod's containers. +// +// Since mutations are often version-gated, the provided feature.Mutation +// should contain the logic to determine if and how the mutation is applied +// based on the component's current version or configuration. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining if the +// Pod has reached its desired state. +// +// The default behavior uses DefaultConvergingStatusHandler, which checks the Pod's +// phase and container statuses. Use this method if your Pod requires more complex +// health checks. +// +// If you want to augment the default behavior, you can call DefaultConvergingStatusHandler +// within your custom handler. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *corev1.Pod) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the Pod reports its health while +// it has not yet reached full readiness. +// +// The default behavior uses DefaultGraceStatusHandler. +// +// If you want to augment the default behavior, you can call DefaultGraceStatusHandler +// within your custom handler. +func (b *Builder) WithCustomGraceStatus( + handler func(*corev1.Pod) (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 always reports +// Suspended because pods are deleted on suspend. +// +// If you want to augment the default behavior, you can call DefaultSuspensionStatusHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*corev1.Pod) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Pod should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op +// because pods are deleted on suspend rather than mutated. +// +// If you want to augment the default behavior, you can call DefaultSuspendMutationHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the Pod when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns true +// because pods cannot be paused. Return false from this handler if you want +// the Pod to remain in the cluster when suspended. +// +// If you want to augment the default behavior, you can call DefaultDeleteOnSuspendHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*corev1.Pod) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// Pod after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields (like pod IP or node +// assignment) and making them available to other components or resources via +// the framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(corev1.Pod) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(p *corev1.Pod) error { + return extractor(*p) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base Pod object was provided. +// - The Pod 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/pod/builder_test.go b/pkg/primitives/pod/builder_test.go new file mode 100644 index 00000000..eea10ba3 --- /dev/null +++ b/pkg/primitives/pod/builder_test.go @@ -0,0 +1,234 @@ +package pod + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/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 + pod *corev1.Pod + expectedErr string + }{ + { + name: "nil pod", + pod: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid pod", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.pod).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, "v1/Pod/test-ns/test-pod", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *corev1.Pod) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusUpdating}, nil + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Pod) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Pod) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Pod) bool { + return false + } + res, err := NewBuilder(pod). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.False(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ corev1.Pod) error { + called = true + return nil + } + res, err := NewBuilder(pod). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&corev1.Pod{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(pod). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/pod/handlers.go b/pkg/primitives/pod/handlers.go new file mode 100644 index 00000000..48b4349d --- /dev/null +++ b/pkg/primitives/pod/handlers.go @@ -0,0 +1,179 @@ +package pod + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" +) + +// DefaultConvergingStatusHandler is the default logic for determining if a Pod has reached its desired state. +// +// It considers a Pod: +// - Healthy: when Status.Phase is Running AND all container statuses report Ready. +// - Failing: when any container is in CrashLoopBackOff or has terminated with an error. +// - Updating: when the converging operation is concepts.ConvergingOperationUpdated. +// - Creating: when Status.Phase is Pending and no restart failures are detected. +// +// 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, pod *corev1.Pod, +) (concepts.AliveStatusWithReason, error) { + // Check for failing containers first + for _, cs := range pod.Status.ContainerStatuses { + if cs.State.Waiting != nil && cs.State.Waiting.Reason == "CrashLoopBackOff" { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusFailing, + Reason: "Container " + cs.Name + " is in CrashLoopBackOff", + }, nil + } + if cs.State.Terminated != nil && cs.State.Terminated.ExitCode != 0 { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusFailing, + Reason: "Container " + cs.Name + " terminated with error", + }, nil + } + } + + // Check if pod is running and all containers are ready + if pod.Status.Phase == corev1.PodRunning { + if len(pod.Status.ContainerStatuses) == 0 { + // Container statuses not yet populated; readiness is unknown. + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod running but container readiness unknown", + }, nil + } + allReady := true + for _, cs := range pod.Status.ContainerStatuses { + if !cs.Ready { + allReady = false + break + } + } + if allReady { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "Pod is running and all containers are ready", + }, nil + } + } + + // Handle terminal phases explicitly. + switch pod.Status.Phase { + case corev1.PodFailed: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusFailing, + Reason: "Pod has failed", + }, nil + case corev1.PodSucceeded: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "Pod has completed successfully", + }, nil + } + + // Pod is Running with not-all-ready containers, or Pending. + // Determine status based on converging operation. + switch op { + case concepts.ConvergingOperationUpdated: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusUpdating, + Reason: "Pod is being updated", + }, nil + case concepts.ConvergingOperationCreated: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod is starting", + }, nil + default: + if pod.Status.Phase == corev1.PodRunning { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod running but not all containers ready", + }, nil + } + reason := "Pod phase is " + string(pod.Status.Phase) + if pod.Status.Phase == corev1.PodPending { + reason = "Pod is pending" + } + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: reason, + }, nil + } +} + +// DefaultGraceStatusHandler provides a default health assessment of the Pod when it has not yet +// reached full readiness. +// +// It categorizes the current state into: +// - GraceStatusHealthy: Pod is Running and all containers are Ready. +// - GraceStatusDegraded: Pod is Running but not all containers are Ready, or container readiness is unknown. +// - GraceStatusDown: Pod phase is not Running. +// +// 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(pod *corev1.Pod) (concepts.GraceStatusWithReason, error) { + if pod.Status.Phase != corev1.PodRunning { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "Pod phase is " + string(pod.Status.Phase), + }, nil + } + + if len(pod.Status.ContainerStatuses) == 0 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "Pod running but container readiness unknown", + }, nil + } + + for _, cs := range pod.Status.ContainerStatuses { + if !cs.Ready { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "Pod running but not all containers ready", + }, nil + } + } + + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusHealthy, + Reason: "Pod running and all containers ready", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the Pod +// when the parent component is suspended. +// +// It always returns true because pods cannot be paused — they must be deleted when suspended. +// +// 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(_ *corev1.Pod) bool { + return true +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a Pod when the component is suspended. +// +// It is a no-op because pods are deleted on suspend rather than mutated. +// +// 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) error { + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It always reports Suspended because pod suspension is handled by deletion — once +// the framework decides to delete the pod, suspension is considered complete. +// +// 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(_ *corev1.Pod) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Pod deleted on suspend", + }, nil +} diff --git a/pkg/primitives/pod/handlers_test.go b/pkg/primitives/pod/handlers_test.go new file mode 100644 index 00000000..b3ecfdbb --- /dev/null +++ b/pkg/primitives/pod/handlers_test.go @@ -0,0 +1,256 @@ +package pod + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func TestDefaultConvergingStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + pod *corev1.Pod + wantStatus concepts.AliveConvergingStatus + wantReason string + }{ + { + name: "healthy - running and all containers ready", + op: concepts.ConvergingOperationUpdated, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "Pod is running and all containers are ready", + }, + { + name: "creating - running with no container statuses", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod running but container readiness unknown", + }, + { + name: "failing - crash loop backoff", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "app", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "CrashLoopBackOff", + }, + }, + }, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusFailing, + wantReason: "Container app is in CrashLoopBackOff", + }, + { + name: "failing - terminated with error", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "worker", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + }, + }, + }, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusFailing, + wantReason: "Container worker terminated with error", + }, + { + name: "creating - on created operation", + op: concepts.ConvergingOperationCreated, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod is starting", + }, + { + name: "updating - on updated operation", + op: concepts.ConvergingOperationUpdated, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Pod is being updated", + }, + { + name: "creating - on none operation when pending", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod is pending", + }, + { + name: "running but not all containers ready", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + {Name: "sidecar", Ready: false}, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod running but not all containers ready", + }, + { + name: "failed pod phase", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + }, + }, + wantStatus: concepts.AliveConvergingStatusFailing, + wantReason: "Pod has failed", + }, + { + name: "succeeded pod phase", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodSucceeded, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "Pod has completed successfully", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.pod) + 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 (running, no container statuses)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "Pod running but container readiness unknown", got.Reason) + }) + + t.Run("degraded (running, not all containers ready)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + {Name: "sidecar", Ready: false}, + }, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "Pod running but not all containers ready", got.Reason) + }) + + t.Run("healthy (running, all containers ready)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + }, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, got.Status) + assert.Equal(t, "Pod running and all containers ready", got.Reason) + }) + + t.Run("down (not running)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDown, got.Status) + assert.Equal(t, "Pod phase is Pending", got.Reason) + }) + + t.Run("down (failed phase)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDown, got.Status) + }) +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + pod := &corev1.Pod{} + assert.True(t, DefaultDeleteOnSuspendHandler(pod)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + pod := &corev1.Pod{} + mutator := NewMutator(pod) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + // No-op; just verify no error +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + pod := &corev1.Pod{} + got, err := DefaultSuspensionStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "Pod deleted on suspend", got.Reason) +} diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go new file mode 100644 index 00000000..67ba543a --- /dev/null +++ b/pkg/primitives/pod/mutator.go @@ -0,0 +1,411 @@ +package pod + +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" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a feature-aware mutation applied by a Pod Mutator. +// If constructed with a non-nil feature.ResourceFeature, it is applied only +// when that feature is enabled; if the feature is nil, the mutation is +// always applied. +type Mutation feature.Mutation[*Mutator] + +type containerEdit struct { + selector selectors.ContainerSelector + edit func(*editors.ContainerEditor) error +} + +type containerPresenceOp struct { + name string + container corev1.Container + remove bool +} + +type featurePlan struct { + podMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit +} + +// Mutator is a high-level helper for modifying a Kubernetes Pod. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the Pod in a single controlled pass when Apply() is called. +// +// This approach ensures that mutations are applied consistently and minimizes +// repeated scans of the underlying Kubernetes structures. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +type Mutator struct { + current *corev1.Pod + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Pod. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the Pod. BeginFeature must be called before registering +// any mutations. +func NewMutator(current *corev1.Pod) *Mutator { + return &Mutator{ + current: current, + } +} + +// requireActive panics with a clear message if BeginFeature has not been called. +func (m *Mutator) requireActive() { + if m.active == nil { + panic("pod.Mutator: BeginFeature() must be called before registering mutations") + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until BeginFeature +// is called again. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Pod'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.requireActive() + m.active.podMetadataEdits = append(m.active.podMetadataEdits, edit) +} + +// EditPodSpec records a mutation for the Pod's 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.requireActive() + m.active.podSpecEdits = append(m.active.podSpecEdits, edit) +} + +// EnsureContainer records that a regular container must be present in the Pod. +// If a container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureContainer(container corev1.Container) { + m.requireActive() + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: container.Name, + container: container, + }) +} + +// RemoveContainer records that a regular container should be removed by name. +func (m *Mutator) RemoveContainer(name string) { + m.requireActive() + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: name, + remove: true, + }) +} + +// RemoveContainers records that multiple regular containers should be removed by name. +func (m *Mutator) RemoveContainers(names []string) { + for _, name := range names { + m.RemoveContainer(name) + } +} + +// 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. +// - 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.requireActive() + m.active.containerEdits = append(m.active.containerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureInitContainer records that an init container must be present in the Pod. +// If an init container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.requireActive() + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: container.Name, + container: container, + }) +} + +// RemoveInitContainer records that an init container should be removed by name. +func (m *Mutator) RemoveInitContainer(name string) { + m.requireActive() + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: name, + remove: true, + }) +} + +// RemoveInitContainers records that multiple init containers should be removed by name. +func (m *Mutator) RemoveInitContainers(names []string) { + for _, name := range names { + m.RemoveInitContainer(name) + } +} + +// 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.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.requireActive() + m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the Pod. +// +// 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 Pod. +// +// 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 Pod. +// +// 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 Pod. +// +// 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 Pod. +// +// 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 Pod. +// +// 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 Pod. +// +// 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. Pod spec edits +// 3. Regular container presence operations +// 4. Regular container edits +// 5. Init container presence operations +// 6. Init container edits +// +// Within each category of a single feature, edits are applied in their registration order. +// +// Selection & Identity: +// - Container selectors target containers in the state they are in at the start of that feature's +// container phase (after presence operations of the SAME feature have been applied). +// - Selector matching within a phase is evaluated against a snapshot of containers at the start +// of that phase, not the progressively mutated live containers. +// - Later features observe the Pod as modified by all previous features. +// +// Timing: +// No changes are made to the Pod until Apply() is called. +// Selectors and edit functions are executed during this pass. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.podMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.podMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.Containers, op) + } + + // 4. Regular container edits + if len(plan.containerEdits) > 0 { + // Take snapshot of containers AFTER presence ops but BEFORE applying any edits for stable selector matching + snapshots := make([]corev1.Container, len(m.current.Spec.Containers)) + for i := range m.current.Spec.Containers { + m.current.Spec.Containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Containers { + container := &m.current.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 + } + } + } + } + } + + // 5. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.InitContainers, op) + } + + // 6. Init container edits + if len(plan.initContainerEdits) > 0 { + // Take snapshot of init containers AFTER presence ops but BEFORE applying any edits + snapshots := make([]corev1.Container, len(m.current.Spec.InitContainers)) + for i := range m.current.Spec.InitContainers { + m.current.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.InitContainers { + container := &m.current.Spec.InitContainers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.initContainerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + } + + return nil +} + +func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { + found := -1 + for i, c := range *containers { + if c.Name == op.name { + found = i + break + } + } + + if op.remove { + if found != -1 { + *containers = append((*containers)[:found], (*containers)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*containers)[found] = op.container + } else { + *containers = append(*containers, op.container) + } +} diff --git a/pkg/primitives/pod/mutator_test.go b/pkg/primitives/pod/mutator_test.go new file mode 100644 index 00000000..1ff72584 --- /dev/null +++ b/pkg/primitives/pod/mutator_test.go @@ -0,0 +1,497 @@ +package pod + +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" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewMutator(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + assert.NotNil(t, m) + assert.Equal(t, pod, 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 TestMutator_EnvVars(t *testing.T) { + pod := &corev1.Pod{ + 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(pod) + 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 := pod.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) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + } + + m := NewMutator(pod) + m.BeginFeature() + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := pod.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_EditContainers(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + } + + m := NewMutator(pod) + 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", pod.Spec.Containers[0].Image) + assert.Equal(t, "", pod.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", pod.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", pod.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + 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", pod.Spec.ServiceAccountName) +} + +func TestMutator_EditMetadata(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"pod": "label"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", pod.Labels["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + 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) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + } + + var order []string + + m := NewMutator(pod) + m.BeginFeature() + // Register in reverse order of expected execution + 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.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "podmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"podmeta", "podspec", "container"} + assert.Equal(t, expected, order) +} + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + } + + m := NewMutator(pod) + 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, pod.Spec.InitContainers[0].Image) +} + +func TestMutator_ContainerPresence(t *testing.T) { + const newImage = "new-image" + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + } + + m := NewMutator(pod) + m.BeginFeature() + // Replace + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + // Remove + m.RemoveContainer("sidecar") + // Append + m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, pod.Spec.Containers, 2) + + assert.Equal(t, "app", pod.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", pod.Spec.Containers[0].Image) + + assert.Equal(t, "new-container", pod.Spec.Containers[1].Name) + assert.Equal(t, newImage, pod.Spec.Containers[1].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + } + + m := NewMutator(pod) + 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, pod.Spec.InitContainers, 1) + + assert.Equal(t, "init-2", pod.Spec.InitContainers[0].Name) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + } + + m := NewMutator(pod) + m.BeginFeature() + + // First edit renames the container + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = appV2 + return nil + }) + + // Second edit should still match using "app" selector because of snapshot + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + // Third edit targeting "app-v2" should NOT match in this apply pass + m.EditContainers(selectors.ContainerNamed(appV2), func(e *editors.ContainerEditor) error { + e.Raw().Image = "should-not-be-set" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, appV2, pod.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", pod.Spec.Containers[0].Image) +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + } + + m := NewMutator(pod) + m.BeginFeature() + + // Register edit first + m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "edited-image" + return nil + }) + + // Register presence later + m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) + + err := m.Apply() + require.NoError(t, err) + + // It should work because presence happens before edits in Apply() + require.Len(t, pod.Spec.Containers, 1) + + assert.Equal(t, "edited-image", pod.Spec.Containers[0].Image) +} + +func TestMutator_NilSafety(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + } + m := NewMutator(pod) + m.BeginFeature() + + // These should all be no-ops and not panic + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditObjectMetadata(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + } + + m := NewMutator(pod) + + // Feature A: sets image to v2 + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B: sets image to v3 + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + // Feature B should win + assert.Equal(t, "v3", pod.Spec.Containers[0].Image) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + } + + m := NewMutator(pod) + m.BeginFeature() + + var executionOrder []string + + // Register in reverse order of expected execution + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + executionOrder = append(executionOrder, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + executionOrder = append(executionOrder, "podspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "podmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + } + + m := NewMutator(pod) + + // Feature A renames container + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Feature B selects by the new name - this should work! + m.BeginFeature() + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2-image" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "app-v2", pod.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", pod.Spec.Containers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + } + + m := NewMutator(pod) + m.BeginFeature() + + // 1. Add init-1 + m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + + // 2. Edit init-1 (it's present in the same feature's phase) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-edited" + return nil + }) + + // 3. Rename it inside the edit phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "init-1-renamed" + return nil + }) + + // 4. Selector targeting "init-1" should still match because of snapshot in same phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-final" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, pod.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", pod.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", pod.Spec.InitContainers[0].Image) +} diff --git a/pkg/primitives/pod/resource.go b/pkg/primitives/pod/resource.go new file mode 100644 index 00000000..7c7eb297 --- /dev/null +++ b/pkg/primitives/pod/resource.go @@ -0,0 +1,123 @@ +package pod + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes Pod 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 deletion-based deactivation. +// - component.DataExtractable: for exporting information after successful reconciliation. +// +// This resource handles the lifecycle of a Pod, including initial creation, +// updates via feature mutations, and status monitoring. +type Resource struct { + base *generic.WorkloadResource[*corev1.Pod, *Mutator] +} + +// Identity returns a unique identifier for the Pod in the format +// "v1/Pod//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific Pod 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 Pod 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 Pod 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 Pod. +// 3. Suspension: If the resource is in a suspending state, the suspension +// logic 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 Pod has successfully reached its desired state. +// +// By default, it uses DefaultConvergingStatusHandler, which checks the Pod's phase +// and container statuses to determine health. +// +// The return value includes a descriptive status (Healthy, Creating, Updating, or Failing) +// 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 Pod when it has not yet +// reached full readiness. +// +// By default, it uses DefaultGraceStatusHandler, which categorizes the current state into: +// - GraceStatusHealthy: Pod is Running and all containers are Ready. +// - GraceStatusDegraded: Pod is Running but not all containers are Ready, or readiness is unknown. +// - GraceStatusDown: Pod phase is not Running. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +// DeleteOnSuspend determines whether the Pod should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns true because +// pods cannot be paused — they must be deleted when suspended. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the Pod. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op +// because pods are deleted on suspend rather than mutated. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which always reports +// Suspended because pod suspension is handled by deletion. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled Pod. +// +// This is called by the framework after a successful reconciliation of the +// resource. It allows the component to export details (like pod IP, node name, +// or status fields) that might be needed by other resources or higher-level +// controllers. +// +// Data extractors are provided with a deep copy of the current Pod to +// prevent accidental mutations during the extraction process. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go new file mode 100644 index 00000000..ec08ed09 --- /dev/null +++ b/pkg/primitives/pod/resource_test.go @@ -0,0 +1,376 @@ +package pod + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidPod()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/Pod/test-ns/test-pod", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + pod := newValidPod() + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.Pod) + require.True(t, ok) + assert.Equal(t, pod.Name, got.Name) + assert.Equal(t, pod.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-pod", pod.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidPod() + desired.Labels = map[string]string{"app": "test"} + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-env", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "FOO", Value: "BAR"}) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.Pod) + assert.Equal(t, "test", got.Labels["app"]) + assert.Equal(t, "BAR", got.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_DeepCopySemantics(t *testing.T) { + desired := newValidPod() + desired.Labels = map[string]string{"app": "test"} + + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + // Modifying the mutated object's labels must not affect the desired object. + got := obj.(*corev1.Pod) + got.Labels["app"] = "modified" + assert.Equal(t, "test", desired.Labels["app"]) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidPod() + + 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.(*corev1.Pod) + assert.Equal(t, "v3", got.Spec.Containers[0].Image) +} + +func TestResource_Mutate_DisabledFeatureSkipped(t *testing.T) { + desired := newValidPod() + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "disabled", + Feature: feature.NewResourceFeature("v1", nil).When(false), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHOULD_NOT_EXIST", Value: "true"}) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.Pod) + assert.Empty(t, got.Spec.Containers[0].Env) +} + +type podMockHandlers struct { + mock.Mock +} + +func (m *podMockHandlers) ConvergingStatus(op concepts.ConvergingOperation, p *corev1.Pod) (concepts.AliveStatusWithReason, error) { + args := m.Called(op, p) + return args.Get(0).(concepts.AliveStatusWithReason), args.Error(1) +} + +func (m *podMockHandlers) GraceStatus(p *corev1.Pod) (concepts.GraceStatusWithReason, error) { + args := m.Called(p) + return args.Get(0).(concepts.GraceStatusWithReason), args.Error(1) +} + +func (m *podMockHandlers) SuspensionStatus(p *corev1.Pod) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(p) + return args.Get(0).(concepts.SuspensionStatusWithReason), args.Error(1) +} + +func (m *podMockHandlers) Suspend(mut *Mutator) error { + args := m.Called(mut) + return args.Error(0) +} + +func (m *podMockHandlers) DeleteOnSuspend(p *corev1.Pod) bool { + args := m.Called(p) + return args.Bool(0) +} + +func TestResource_ConvergingStatus(t *testing.T) { + pod := newValidPod() + pod.Status.Phase = corev1.PodRunning + pod.Status.ContainerStatuses = []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + } + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + statusReady := concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusHealthy} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, pod).Return(statusReady, nil) + + res, err := NewBuilder(pod). + 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("uses default handler", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusHealthy, status.Status) + }) +} + +func TestResource_GraceStatus(t *testing.T) { + pod := newValidPod() + pod.Status.Phase = corev1.PodRunning + pod.Status.ContainerStatuses = []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + } + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + statusReady := concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy} + m.On("GraceStatus", pod).Return(statusReady, nil) + + res, err := NewBuilder(pod). + 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("uses default handler", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + status, err := res.GraceStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + pod := newValidPod() + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + m.On("DeleteOnSuspend", pod).Return(false) + + res, err := NewBuilder(pod). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default (true for pods)", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + pod := newValidPod() + + t.Run("registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + err = res.Mutate(obj) + require.NoError(t, err) + // Default suspend mutation is a no-op for pods (they are deleted instead). + // Just verify Mutate succeeds. + }) + + t.Run("uses custom suspend mutation handler", func(t *testing.T) { + m := &podMockHandlers{} + m.On("Suspend", mock.Anything).Return(nil).Run(func(args mock.Arguments) { + mut := args.Get(0).(*Mutator) + mut.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("suspended", "true") + return nil + }) + }) + + res, err := NewBuilder(pod). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + err = res.Mutate(obj) + require.NoError(t, err) + + m.AssertExpectations(t) + got := obj.(*corev1.Pod) + assert.Equal(t, "true", got.Labels["suspended"]) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + pod := newValidPod() + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", pod).Return(statusSuspended, nil) + + res, err := NewBuilder(pod). + 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(pod).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + assert.Equal(t, "Pod deleted on suspend", status.Reason) + }) +} + +func TestResource_ExtractData(t *testing.T) { + pod := newValidPod() + + extractedImage := "" + res, err := NewBuilder(pod). + WithDataExtractor(func(p corev1.Pod) error { + extractedImage = p.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.NoError(t, err) + assert.Equal(t, "nginx:latest", extractedImage) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidPod()). + WithDataExtractor(func(_ corev1.Pod) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +}