diff --git a/Makefile b/Makefile index d290e9dc..466f638d 100644 --- a/Makefile +++ b/Makefile @@ -125,6 +125,7 @@ run-examples: ## Run all examples to verify they execute without error. go run ./examples/replicaset-primitive/. go run ./examples/rolebinding-primitive/. go run ./examples/custom-resource-implementation/. + go run ./examples/pdb-primitive/. go run ./examples/daemonset-primitive/. go run ./examples/hpa-primitive/. go run ./examples/clusterrolebinding-primitive/. diff --git a/docs/primitives.md b/docs/primitives.md index 8b4eaee0..8038b657 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -114,19 +114,20 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: -| Editor | Scope | -| ----------------------- | --------------------------------------------------------------------------------- | -| `ContainerEditor` | Environment variables, arguments, resource limits, ports | -| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `ReplicaSetSpecEditor` | Replicas, min ready seconds | -| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | -| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access | -| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw | -| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests | -| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration | -| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| Editor | Scope | +| ------------------------------- | --------------------------------------------------------------------------------- | +| `ContainerEditor` | Environment variables, arguments, resource limits, ports | +| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `ReplicaSetSpecEditor` | Replicas, min ready seconds | +| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit | +| `PodDisruptionBudgetSpecEditor` | MinAvailable, MaxUnavailable, selector, eviction policy | +| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access | +| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw | +| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests | +| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. @@ -154,6 +155,7 @@ have been applied. This means a single mutation can safely add a container and t | `pkg/primitives/daemonset` | Workload | [daemonset.md](primitives/daemonset.md) | | `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) | | `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) | | `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | | `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) | diff --git a/docs/primitives/pdb.md b/docs/primitives/pdb.md new file mode 100644 index 00000000..65386d8f --- /dev/null +++ b/docs/primitives/pdb.md @@ -0,0 +1,251 @@ +# PodDisruptionBudget Primitive + +The `pdb` primitive is the framework's built-in static abstraction for managing Kubernetes `PodDisruptionBudget` +resources. It integrates with the component lifecycle and provides a structured mutation API for managing disruption +policies and object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ---------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for PDB spec and object metadata, with a raw escape hatch for free-form access | +| **Data extraction** | Reads generated or updated values back from the reconciled PDB after each sync cycle | + +## Building a PDB Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pdb" + +minAvailable := intstr.FromString("50%") +base := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "web-server-pdb", + Namespace: owner.Namespace, + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MinAvailable: &minAvailable, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web-server"}, + }, + }, +} + +resource, err := pdb.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `PodDisruptionBudget` 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) pdb.Mutation { + return pdb.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *pdb.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 + +```go +func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation { + return pdb.Mutation{ + Name: "strict-availability", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *pdb.Mutator) error { + return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil + }) + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyPDBMutation(version string) pdb.Mutation { + return pdb.Mutation{ + Name: "legacy-pdb", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *pdb.Mutator) error { + return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMinAvailable(intstr.FromInt32(1)) + 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 applied in a fixed category order regardless of the order they are +recorded: + +| Step | Category | What it affects | +| ---- | -------------- | ------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `PodDisruptionBudget` | +| 2 | Spec edits | MinAvailable, MaxUnavailable, selector, eviction policy | + +Within each category, edits are applied in their registration order. Later features observe the PodDisruptionBudget as +modified by all previous features. + +## Editors + +### PodDisruptionBudgetSpecEditor + +The primary API for modifying the PDB spec. Use `m.EditSpec` for full control: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMinAvailable(intstr.FromString("50%")) + e.SetSelector(&metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + }) + return nil +}) +``` + +#### SetMinAvailable and SetMaxUnavailable + +`SetMinAvailable` sets the minimum number of pods that must remain available during a disruption. `SetMaxUnavailable` +sets the maximum number of pods that can be unavailable. Both accept `intstr.IntOrString` — either an integer count or a +percentage string (e.g. `"50%"`). + +These fields are mutually exclusive in the Kubernetes API. Use `ClearMinAvailable` or `ClearMaxUnavailable` to remove +the opposing constraint when switching between them: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil +}) +``` + +#### SetSelector + +`SetSelector` replaces the pod selector used by the PDB: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetSelector(&metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web", "tier": "frontend"}, + }) + return nil +}) +``` + +#### SetUnhealthyPodEvictionPolicy + +`SetUnhealthyPodEvictionPolicy` controls how unhealthy pods are handled during eviction. Valid values are +`policyv1.IfHealthyBudget` and `policyv1.AlwaysAllow`. Use `ClearUnhealthyPodEvictionPolicy` to revert to the cluster +default: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetUnhealthyPodEvictionPolicy(policyv1.AlwaysAllow) + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*policyv1.PodDisruptionBudgetSpec` for direct access when the typed API is insufficient: + +```go +m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.Raw().MinAvailable = &customValue + 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) + e.EnsureAnnotation("pdb.example.io/policy", "strict") + return nil +}) +``` + +## Full Example: Feature-Gated Disruption Policy + +```go +func BasePDBMutation(version string) pdb.Mutation { + return pdb.Mutation{ + Name: "base-pdb", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *pdb.Mutator) error { + return m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + }, + } +} + +func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation { + return pdb.Mutation{ + Name: "strict-availability", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *pdb.Mutator) error { + return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil + }) + }, + } +} + +resource, err := pdb.NewBuilder(base). + WithMutation(BasePDBMutation(owner.Spec.Version)). + WithMutation(StrictAvailabilityMutation(owner.Spec.Version, owner.Spec.StrictMode)). + Build() +``` + +When `StrictMode` is true, the PDB switches from percentage-based `MinAvailable` to an absolute `MaxUnavailable` of 1. +When false, only the base mutation runs and the original `MinAvailable` from the baseline is preserved. Neither mutation +needs to know about the other. + +## 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. + +**`MinAvailable` and `MaxUnavailable` are mutually exclusive.** When switching between them, always clear the opposing +field first. The typed API makes this explicit with `ClearMinAvailable` and `ClearMaxUnavailable`. + +**Register mutations in dependency order.** If mutation B relies on state set by mutation A, register A first. diff --git a/examples/pdb-primitive/README.md b/examples/pdb-primitive/README.md new file mode 100644 index 00000000..bb8b20c5 --- /dev/null +++ b/examples/pdb-primitive/README.md @@ -0,0 +1,31 @@ +# PodDisruptionBudget Primitive Example + +This example demonstrates the usage of the `pdb` primitive within the operator component framework. It shows how to +manage a Kubernetes PodDisruptionBudget as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a PDB with a percentage-based `MinAvailable` and a label selector. +- **Feature Mutations**: Switching between `MinAvailable` and `MaxUnavailable` based on a feature toggle via `EditSpec`. +- **Metadata Mutations**: Setting version labels on the PDB via `EditObjectMetadata`. +- **Data Extraction**: Inspecting the reconciled PDB's disruption policy after each sync cycle. + +## Directory Structure + +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from + `examples/shared/app`. +- `features/`: Contains modular feature definitions: + - `mutations.go`: version labelling and feature-gated strict availability. +- `resources/`: Contains the central `NewPDBResource` factory that assembles all features using `pdb.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/pdb-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile through four spec variations, printing the PDB disruption policy after each cycle. +4. Print the resulting status conditions. diff --git a/examples/pdb-primitive/app/controller.go b/examples/pdb-primitive/app/controller.go new file mode 100644 index 00000000..a65d68a0 --- /dev/null +++ b/examples/pdb-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the PDB primitive. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewPDBResource is a factory function to create the PDB resource. + // This allows us to inject the resource construction logic. + NewPDBResource func(*sharedapp.ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error { + // 1. Build the PDB resource for this owner. + pdbResource, err := r.NewPDBResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the PDB. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(pdbResource, component.ResourceOptions{}). + 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/pdb-primitive/features/mutations.go b/examples/pdb-primitive/features/mutations.go new file mode 100644 index 00000000..1029b82a --- /dev/null +++ b/examples/pdb-primitive/features/mutations.go @@ -0,0 +1,41 @@ +// Package features provides sample mutations for the PDB primitive example. +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/pdb" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// VersionLabelMutation sets the app.kubernetes.io/version label on the PDB. +// It is always enabled. +func VersionLabelMutation(version string) pdb.Mutation { + return pdb.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *pdb.Mutator) error { + return m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + }, + } +} + +// StrictAvailabilityMutation switches the PDB from percentage-based MinAvailable +// to an absolute MaxUnavailable of 1 when metrics are enabled, indicating that +// the service requires stricter disruption control. +func StrictAvailabilityMutation(version string, metricsEnabled bool) pdb.Mutation { + return pdb.Mutation{ + Name: "strict-availability", + Feature: feature.NewResourceFeature(version, nil).When(metricsEnabled), + Mutate: func(m *pdb.Mutator) error { + return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.ClearMinAvailable() + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil + }) + }, + } +} diff --git a/examples/pdb-primitive/main.go b/examples/pdb-primitive/main.go new file mode 100644 index 00000000..abbc5060 --- /dev/null +++ b/examples/pdb-primitive/main.go @@ -0,0 +1,110 @@ +// Package main is the entry point for the PDB 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/pdb-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pdb-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + policyv1 "k8s.io/api/policy/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. + scheme := runtime.NewScheme() + if err := sharedapp.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := policyv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add policy/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&sharedapp.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.0.0", + EnableMetrics: 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 the controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + NewPDBResource: resources.NewPDBResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // feature-gated mutations modify the PDB disruption policy. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.0.0", + EnableMetrics: false, + }, + { + Version: "1.1.0", // Version upgrade + EnableMetrics: false, + }, + { + Version: "1.1.0", + EnableMetrics: true, // Enable metrics → stricter availability + }, + { + Version: "1.1.0", + EnableMetrics: false, // Disable metrics → back to default + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v ---\n", + i+1, spec.Version, spec.EnableMetrics) + + 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) + } + + 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/pdb-primitive/resources/pdb.go b/examples/pdb-primitive/resources/pdb.go new file mode 100644 index 00000000..30f7fb95 --- /dev/null +++ b/examples/pdb-primitive/resources/pdb.go @@ -0,0 +1,59 @@ +// Package resources provides resource implementations for the PDB primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/pdb-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/pdb" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// NewPDBResource constructs a PDB primitive resource with all the features. +func NewPDBResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base PodDisruptionBudget object. + minAvailable := intstr.FromString("50%") + base := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-pdb", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MinAvailable: &minAvailable, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": owner.Name, + }, + }, + }, + } + + // 2. Initialize the PDB builder. + builder := pdb.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.StrictAvailabilityMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Extract data from the reconciled PDB. + builder.WithDataExtractor(func(p policyv1.PodDisruptionBudget) error { + fmt.Printf("Reconciled PDB: %s\n", p.Name) + if p.Spec.MinAvailable != nil { + fmt.Printf(" MinAvailable: %s\n", p.Spec.MinAvailable.String()) + } + if p.Spec.MaxUnavailable != nil { + fmt.Printf(" MaxUnavailable: %s\n", p.Spec.MaxUnavailable.String()) + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/pdbspec.go b/pkg/mutation/editors/pdbspec.go new file mode 100644 index 00000000..3e982647 --- /dev/null +++ b/pkg/mutation/editors/pdbspec.go @@ -0,0 +1,68 @@ +package editors + +import ( + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// PodDisruptionBudgetSpecEditor provides a typed API for mutating a Kubernetes +// PodDisruptionBudgetSpec. +type PodDisruptionBudgetSpecEditor struct { + spec *policyv1.PodDisruptionBudgetSpec +} + +// NewPodDisruptionBudgetSpecEditor creates a new PodDisruptionBudgetSpecEditor for +// the given PodDisruptionBudgetSpec. +func NewPodDisruptionBudgetSpecEditor(spec *policyv1.PodDisruptionBudgetSpec) *PodDisruptionBudgetSpecEditor { + return &PodDisruptionBudgetSpecEditor{spec: spec} +} + +// Raw returns the underlying *policyv1.PodDisruptionBudgetSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *PodDisruptionBudgetSpecEditor) Raw() *policyv1.PodDisruptionBudgetSpec { + return e.spec +} + +// SetMinAvailable sets the minimum number of pods that must be available after an eviction. +// Accepts either an integer or a percentage string (e.g. "50%"). +func (e *PodDisruptionBudgetSpecEditor) SetMinAvailable(val intstr.IntOrString) { + e.spec.MinAvailable = &val +} + +// SetMaxUnavailable sets the maximum number of pods that can be unavailable after an eviction. +// Accepts either an integer or a percentage string (e.g. "25%"). +func (e *PodDisruptionBudgetSpecEditor) SetMaxUnavailable(val intstr.IntOrString) { + e.spec.MaxUnavailable = &val +} + +// ClearMinAvailable removes the MinAvailable constraint. +func (e *PodDisruptionBudgetSpecEditor) ClearMinAvailable() { + e.spec.MinAvailable = nil +} + +// ClearMaxUnavailable removes the MaxUnavailable constraint. +func (e *PodDisruptionBudgetSpecEditor) ClearMaxUnavailable() { + e.spec.MaxUnavailable = nil +} + +// SetSelector replaces the pod selector used by the PodDisruptionBudget. +func (e *PodDisruptionBudgetSpecEditor) SetSelector(selector *metav1.LabelSelector) { + e.spec.Selector = selector +} + +// SetUnhealthyPodEvictionPolicy sets the unhealthy pod eviction policy. +// +// Valid values are policyv1.IfHealthyBudget and policyv1.AlwaysAllow. +func (e *PodDisruptionBudgetSpecEditor) SetUnhealthyPodEvictionPolicy( + policy policyv1.UnhealthyPodEvictionPolicyType, +) { + e.spec.UnhealthyPodEvictionPolicy = &policy +} + +// ClearUnhealthyPodEvictionPolicy removes the unhealthy pod eviction policy, +// reverting to the cluster default. +func (e *PodDisruptionBudgetSpecEditor) ClearUnhealthyPodEvictionPolicy() { + e.spec.UnhealthyPodEvictionPolicy = nil +} diff --git a/pkg/mutation/editors/pdbspec_test.go b/pkg/mutation/editors/pdbspec_test.go new file mode 100644 index 00000000..b9ae49db --- /dev/null +++ b/pkg/mutation/editors/pdbspec_test.go @@ -0,0 +1,121 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestPodDisruptionBudgetSpecEditor_SetMinAvailable(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.SetMinAvailable(intstr.FromInt32(2)) + + require.NotNil(t, spec.MinAvailable) + assert.Equal(t, intstr.FromInt32(2), *spec.MinAvailable) +} + +func TestPodDisruptionBudgetSpecEditor_SetMinAvailable_Percentage(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.SetMinAvailable(intstr.FromString("50%")) + + require.NotNil(t, spec.MinAvailable) + assert.Equal(t, intstr.FromString("50%"), *spec.MinAvailable) +} + +func TestPodDisruptionBudgetSpecEditor_SetMaxUnavailable(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.SetMaxUnavailable(intstr.FromInt32(1)) + + require.NotNil(t, spec.MaxUnavailable) + assert.Equal(t, intstr.FromInt32(1), *spec.MaxUnavailable) +} + +func TestPodDisruptionBudgetSpecEditor_SetMaxUnavailable_Percentage(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.SetMaxUnavailable(intstr.FromString("25%")) + + require.NotNil(t, spec.MaxUnavailable) + assert.Equal(t, intstr.FromString("25%"), *spec.MaxUnavailable) +} + +func TestPodDisruptionBudgetSpecEditor_ClearMinAvailable(t *testing.T) { + t.Parallel() + val := intstr.FromInt32(2) + spec := &policyv1.PodDisruptionBudgetSpec{MinAvailable: &val} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.ClearMinAvailable() + + assert.Nil(t, spec.MinAvailable) +} + +func TestPodDisruptionBudgetSpecEditor_ClearMaxUnavailable(t *testing.T) { + t.Parallel() + val := intstr.FromInt32(1) + spec := &policyv1.PodDisruptionBudgetSpec{MaxUnavailable: &val} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.ClearMaxUnavailable() + + assert.Nil(t, spec.MaxUnavailable) +} + +func TestPodDisruptionBudgetSpecEditor_SetSelector(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "my-app"}, + } + e.SetSelector(selector) + + require.NotNil(t, spec.Selector) + assert.Equal(t, selector, spec.Selector) +} + +func TestPodDisruptionBudgetSpecEditor_SetUnhealthyPodEvictionPolicy(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.SetUnhealthyPodEvictionPolicy(policyv1.AlwaysAllow) + + require.NotNil(t, spec.UnhealthyPodEvictionPolicy) + assert.Equal(t, policyv1.AlwaysAllow, *spec.UnhealthyPodEvictionPolicy) +} + +func TestPodDisruptionBudgetSpecEditor_ClearUnhealthyPodEvictionPolicy(t *testing.T) { + t.Parallel() + policy := policyv1.IfHealthyBudget + spec := &policyv1.PodDisruptionBudgetSpec{UnhealthyPodEvictionPolicy: &policy} + e := NewPodDisruptionBudgetSpecEditor(spec) + + e.ClearUnhealthyPodEvictionPolicy() + + assert.Nil(t, spec.UnhealthyPodEvictionPolicy) +} + +func TestPodDisruptionBudgetSpecEditor_Raw(t *testing.T) { + t.Parallel() + spec := &policyv1.PodDisruptionBudgetSpec{} + e := NewPodDisruptionBudgetSpecEditor(spec) + + assert.Same(t, spec, e.Raw()) +} diff --git a/pkg/primitives/pdb/builder.go b/pkg/primitives/pdb/builder.go new file mode 100644 index 00000000..008fa5eb --- /dev/null +++ b/pkg/primitives/pdb/builder.go @@ -0,0 +1,79 @@ +package pdb + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + policyv1 "k8s.io/api/policy/v1" +) + +// Builder is a configuration helper for creating and customizing a PodDisruptionBudget Resource. +// +// It provides a fluent API for registering mutations and data extractors. +// Build() validates the configuration and returns an initialized Resource +// ready for use in a reconciliation loop. +type Builder struct { + base *generic.StaticBuilder[*policyv1.PodDisruptionBudget, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided PodDisruptionBudget object. +// +// The PodDisruptionBudget object serves as the desired base state. During reconciliation +// the Resource will make the cluster's state match this base, modified by any +// registered mutations. +// +// The provided PodDisruptionBudget must have both Name and Namespace set, which is +// validated during the Build() call. +func NewBuilder(p *policyv1.PodDisruptionBudget) *Builder { + identityFunc := func(pdb *policyv1.PodDisruptionBudget) string { + return fmt.Sprintf("policy/v1/PodDisruptionBudget/%s/%s", pdb.Namespace, pdb.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*policyv1.PodDisruptionBudget, *Mutator]( + p, + identityFunc, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the PodDisruptionBudget. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithDataExtractor registers a function to read values from the PodDisruptionBudget +// after it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled PodDisruptionBudget. This is +// useful for surfacing generated or updated values to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(policyv1.PodDisruptionBudget) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(p *policyv1.PodDisruptionBudget) error { + return extractor(*p) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No PodDisruptionBudget object was provided. +// - The PodDisruptionBudget is missing a Name or Namespace. +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/pdb/builder_test.go b/pkg/primitives/pdb/builder_test.go new file mode 100644 index 00000000..12d93089 --- /dev/null +++ b/pkg/primitives/pdb/builder_test.go @@ -0,0 +1,122 @@ +package pdb + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder_Build_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pdb *policyv1.PodDisruptionBudget + expectedErr string + }{ + { + name: "nil pdb", + pdb: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + pdb: &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + pdb: &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pdb"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid pdb", + pdb: &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pdb", Namespace: "test-ns"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.pdb).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, "policy/v1/PodDisruptionBudget/test-ns/test-pdb", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + p := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pdb", Namespace: "test-ns"}, + } + res, err := NewBuilder(p). + WithMutation(Mutation{Name: "test-mutation"}). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) +} + +func TestBuilder_WithDataExtractor(t *testing.T) { + t.Parallel() + p := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pdb", Namespace: "test-ns"}, + } + called := false + extractor := func(_ policyv1.PodDisruptionBudget) error { + called = true + return nil + } + res, err := NewBuilder(p). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&policyv1.PodDisruptionBudget{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + p := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pdb", Namespace: "test-ns"}, + } + res, err := NewBuilder(p). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + p := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pdb", Namespace: "test-ns"}, + } + res, err := NewBuilder(p). + WithDataExtractor(func(_ policyv1.PodDisruptionBudget) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&policyv1.PodDisruptionBudget{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/pdb/mutator.go b/pkg/primitives/pdb/mutator.go new file mode 100644 index 00000000..cb4721ac --- /dev/null +++ b/pkg/primitives/pdb/mutator.go @@ -0,0 +1,128 @@ +package pdb + +import ( + "errors" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + policyv1 "k8s.io/api/policy/v1" +) + +// ErrNoActiveFeature is returned when EditObjectMetadata or EditSpec is called +// before BeginFeature has started a feature scope. +var ErrNoActiveFeature = errors.New("pdb mutator: no active feature scope; call BeginFeature first") + +// Mutation defines a mutation that is applied to a pdb Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + specEdits []func(*editors.PodDisruptionBudgetSpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes PodDisruptionBudget. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the PodDisruptionBudget 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. +// +// Unlike other primitive mutators, EditObjectMetadata and EditSpec return an +// error when a non-nil edit function is provided without an active feature +// scope (i.e. before BeginFeature is called). Nil edit functions are always +// ignored and return nil. This means Mutator does not satisfy editors.ObjectMutator. +type Mutator struct { + pdb *policyv1.PodDisruptionBudget + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given PodDisruptionBudget. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the PodDisruptionBudget. BeginFeature must be called before +// registering any mutations. +func NewMutator(pdb *policyv1.PodDisruptionBudget) *Mutator { + return &Mutator{ + pdb: pdb, + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the PodDisruptionBudget's own metadata. +// +// Metadata edits are applied before spec edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) error { + if edit == nil { + return nil + } + if m.active == nil { + return ErrNoActiveFeature + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) + return nil +} + +// EditSpec records a mutation for the PodDisruptionBudget's spec via a +// PodDisruptionBudgetSpecEditor. +// +// The editor provides structured operations (SetMinAvailable, SetMaxUnavailable, +// SetSelector, etc.) as well as Raw() for free-form access. Spec edits are applied +// after metadata edits within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditSpec(edit func(*editors.PodDisruptionBudgetSpecEditor) error) error { + if edit == nil { + return nil + } + if m.active == nil { + return ErrNoActiveFeature + } + m.active.specEdits = append(m.active.specEdits, edit) + return nil +} + +// Apply executes all recorded mutation intents on the underlying PodDisruptionBudget. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Spec edits — EditSpec (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the PodDisruptionBudget as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.pdb.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Spec edits + if len(plan.specEdits) > 0 { + editor := editors.NewPodDisruptionBudgetSpecEditor(&m.pdb.Spec) + for _, edit := range plan.specEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/pdb/mutator_test.go b/pkg/primitives/pdb/mutator_test.go new file mode 100644 index 00000000..b353e48e --- /dev/null +++ b/pkg/primitives/pdb/mutator_test.go @@ -0,0 +1,279 @@ +package pdb + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func newTestPDB() *policyv1.PodDisruptionBudget { + return &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pdb", + Namespace: "default", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + })) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", p.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditObjectMetadata(nil)) + assert.NoError(t, m.Apply()) +} + +// --- EditSpec --- + +func TestMutator_EditSpec_SetMinAvailable(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMinAvailable(intstr.FromInt32(2)) + return nil + })) + require.NoError(t, m.Apply()) + require.NotNil(t, p.Spec.MinAvailable) + assert.Equal(t, intstr.FromInt32(2), *p.Spec.MinAvailable) +} + +func TestMutator_EditSpec_SetMaxUnavailable(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMaxUnavailable(intstr.FromString("25%")) + return nil + })) + require.NoError(t, m.Apply()) + require.NotNil(t, p.Spec.MaxUnavailable) + assert.Equal(t, intstr.FromString("25%"), *p.Spec.MaxUnavailable) +} + +func TestMutator_EditSpec_SetSelector(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + } + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetSelector(selector) + return nil + })) + require.NoError(t, m.Apply()) + require.NotNil(t, p.Spec.Selector) + assert.Equal(t, selector, p.Spec.Selector) +} + +func TestMutator_EditSpec_SetUnhealthyPodEvictionPolicy(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetUnhealthyPodEvictionPolicy(policyv1.AlwaysAllow) + return nil + })) + require.NoError(t, m.Apply()) + require.NotNil(t, p.Spec.UnhealthyPodEvictionPolicy) + assert.Equal(t, policyv1.AlwaysAllow, *p.Spec.UnhealthyPodEvictionPolicy) +} + +func TestMutator_EditSpec_RawAccess(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + val := intstr.FromInt32(3) + e.Raw().MinAvailable = &val + return nil + })) + require.NoError(t, m.Apply()) + require.NotNil(t, p.Spec.MinAvailable) + assert.Equal(t, intstr.FromInt32(3), *p.Spec.MinAvailable) +} + +func TestMutator_EditSpec_Nil(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(nil)) + assert.NoError(t, m.Apply()) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before spec edits. + // The spec edit reads a label set by the metadata edit to prove ordering. + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + // Register in reverse logical order to confirm Apply() enforces category ordering. + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + // Branch on the label set by the metadata edit: if metadata ran first + // the label exists and we set MinAvailable to 1; otherwise we set it to 99. + if p.Labels["order"] == "metadata-first" { + e.SetMinAvailable(intstr.FromInt32(1)) + } else { + e.SetMinAvailable(intstr.FromInt32(99)) + } + return nil + })) + require.NoError(t, m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "metadata-first") + return nil + })) + require.NoError(t, m.Apply()) + + assert.Equal(t, "metadata-first", p.Labels["order"]) + require.NotNil(t, p.Spec.MinAvailable) + // MinAvailable == 1 proves metadata edits ran before spec edits. + // If ordering regressed, MinAvailable would be 99. + assert.Equal(t, intstr.FromInt32(1), *p.Spec.MinAvailable) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature1", "on") + return nil + })) + m.BeginFeature() + require.NoError(t, m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature2", "on") + return nil + })) + require.NoError(t, m.Apply()) + + assert.Equal(t, "on", p.Labels["feature1"]) + assert.Equal(t, "on", p.Labels["feature2"]) +} + +func TestMutator_EditSpec_ErrorPropagated(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(func(_ *editors.PodDisruptionBudgetSpecEditor) error { + return assert.AnError + })) + err := m.Apply() + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestMutator_EditObjectMetadata_ErrorPropagated(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return assert.AnError + })) + err := m.Apply() + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + + 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) { + p := newTestPDB() + m := NewMutator(p) + + 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) { + p := newTestPDB() + m := NewMutator(p) + + // Record a mutation in the first feature plan + m.BeginFeature() + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMinAvailable(intstr.FromInt32(1)) + return nil + })) + + // Start a new feature and record a different mutation + m.BeginFeature() + require.NoError(t, m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("f1", "on") + return nil + })) + + // The first plan should have exactly one spec edit and no metadata edits + assert.Len(t, m.plans[0].specEdits, 1, "first plan should have one spec edit") + assert.Empty(t, m.plans[0].metadataEdits, "first plan should have no metadata edits") + // The second plan should have exactly one metadata edit and no spec edits + assert.Len(t, m.plans[1].metadataEdits, 1, "second plan should have one metadata edit") + assert.Empty(t, m.plans[1].specEdits, "second plan should have no spec edits") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + p := newTestPDB() + m := NewMutator(p) + m.BeginFeature() + require.NoError(t, m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMinAvailable(intstr.FromInt32(1)) + return nil + })) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + require.NotNil(t, p.Spec.MinAvailable) + assert.Equal(t, intstr.FromInt32(1), *p.Spec.MinAvailable) +} + +// --- ErrNoActiveFeature --- + +func TestMutator_EditObjectMetadata_NoActiveFeature(t *testing.T) { + m := NewMutator(newTestPDB()) + err := m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return nil + }) + require.ErrorIs(t, err, ErrNoActiveFeature) +} + +func TestMutator_EditSpec_NoActiveFeature(t *testing.T) { + m := NewMutator(newTestPDB()) + err := m.EditSpec(func(_ *editors.PodDisruptionBudgetSpecEditor) error { + return nil + }) + require.ErrorIs(t, err, ErrNoActiveFeature) +} diff --git a/pkg/primitives/pdb/resource.go b/pkg/primitives/pdb/resource.go new file mode 100644 index 00000000..7624f813 --- /dev/null +++ b/pkg/primitives/pdb/resource.go @@ -0,0 +1,56 @@ +// Package pdb provides a builder and resource for managing Kubernetes PodDisruptionBudgets. +package pdb + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + policyv1 "k8s.io/api/policy/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes PodDisruptionBudget +// within a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.DataExtractable: for exporting values after successful reconciliation. +// +// PodDisruptionBudget resources are static: they do not model convergence health, +// grace periods, or suspension. Use a workload or task primitive for resources +// that require those concepts. +type Resource struct { + base *generic.StaticResource[*policyv1.PodDisruptionBudget, *Mutator] +} + +// Identity returns a unique identifier for the PodDisruptionBudget in the format +// "policy/v1/PodDisruptionBudget//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes PodDisruptionBudget object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes PodDisruptionBudget into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled PodDisruptionBudget. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the PodDisruptionBudget. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/pdb/resource_test.go b/pkg/primitives/pdb/resource_test.go new file mode 100644 index 00000000..f39122ce --- /dev/null +++ b/pkg/primitives/pdb/resource_test.go @@ -0,0 +1,138 @@ +package pdb + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" +) + +func newValidPDB() *policyv1.PodDisruptionBudget { + return &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pdb", + Namespace: "test-ns", + }, + Spec: policyv1.PodDisruptionBudgetSpec{ + MinAvailable: ptr.To(intstr.FromInt32(2)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "test", + }, + }, + }, + } +} +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidPDB()).Build() + require.NoError(t, err) + assert.Equal(t, "policy/v1/PodDisruptionBudget/test-ns/test-pdb", res.Identity()) +} +func TestResource_Object(t *testing.T) { + p := newValidPDB() + res, err := NewBuilder(p).Build() + require.NoError(t, err) + obj, err := res.Object() + require.NoError(t, err) + got, ok := obj.(*policyv1.PodDisruptionBudget) + require.True(t, ok) + assert.Equal(t, p.Name, got.Name) + assert.Equal(t, p.Namespace, got.Namespace) + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-pdb", p.Name) +} +func TestResource_Mutate(t *testing.T) { + desired := newValidPDB() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*policyv1.PodDisruptionBudget) + assert.Equal(t, intstr.FromInt32(2), *got.Spec.MinAvailable) +} +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidPDB() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "set-max-unavailable", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { + e.SetMaxUnavailable(intstr.FromInt32(1)) + return nil + }) + }, + }). + Build() + require.NoError(t, err) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*policyv1.PodDisruptionBudget) + require.NotNil(t, got.Spec.MaxUnavailable) + assert.Equal(t, intstr.FromInt32(1), *got.Spec.MaxUnavailable) +} +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidPDB() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + return m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "a") + return nil + }) + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + return m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "b") + return nil + }) + }, + }). + Build() + require.NoError(t, err) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*policyv1.PodDisruptionBudget) + assert.Equal(t, "b", got.Labels["order"]) +} +func TestResource_ExtractData(t *testing.T) { + p := newValidPDB() + var extracted int32 + res, err := NewBuilder(p). + WithDataExtractor(func(pdb policyv1.PodDisruptionBudget) error { + extracted = pdb.Spec.MinAvailable.IntVal + return nil + }). + Build() + require.NoError(t, err) + require.NoError(t, res.ExtractData()) + assert.Equal(t, int32(2), extracted) +} +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidPDB()). + WithDataExtractor(func(_ policyv1.PodDisruptionBudget) 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") +}