diff --git a/Makefile b/Makefile index 8acfd884..1575573f 100644 --- a/Makefile +++ b/Makefile @@ -127,6 +127,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/service-primitive/. go run ./examples/role-primitive/. go run ./examples/pdb-primitive/. go run ./examples/daemonset-primitive/. diff --git a/docs/primitives.md b/docs/primitives.md index b92b2016..64df927b 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -167,6 +167,7 @@ have been applied. This means a single mutation can safely add a container and t | `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/service` | Integration | [service.md](primitives/service.md) | | `pkg/primitives/pv` | Integration | [pv.md](primitives/pv.md) | | `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) | | `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) | diff --git a/docs/primitives/service.md b/docs/primitives/service.md new file mode 100644 index 00000000..f831a605 --- /dev/null +++ b/docs/primitives/service.md @@ -0,0 +1,316 @@ +# Service Primitive + +The `service` primitive is the framework's built-in integration abstraction for managing Kubernetes `Service` resources. +It integrates with the component lifecycle and provides a structured mutation API for managing ports, selectors, and +service configuration. + +## Capabilities + +| Capability | Detail | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| **Operational tracking** | Monitors LoadBalancer ingress assignment; reports `Operational` or `Pending` | +| **Suspension** | Unaffected by suspension by default; customizable via handlers to delete or mutate on suspend | +| **Mutation pipeline** | Typed editors for metadata and service spec, with a raw escape hatch for free-form access | +| **Data extraction** | Reads generated or updated values (ClusterIP, LoadBalancer ingress) after each sync cycle | + +## Building a Service Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/service" + +base := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-service", + Namespace: owner.Namespace, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": owner.Name}, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080)}, + }, + }, +} + +resource, err := service.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Service` 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) service.Mutation { + return service.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *service.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 NodePortMutation(version string, enabled bool) service.Mutation { + return service.Mutation{ + Name: "nodeport", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.SetType(corev1.ServiceTypeNodePort) + return nil + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +Pass a `[]feature.VersionConstraint` to gate on a semver range: + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyPortMutation(version string) service.Mutation { + return service.Mutation{ + Name: "legacy-port", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "legacy", Port: 9090}) + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded: + +| Step | Category | What it affects | +| ---- | ----------------- | ---------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Service` | +| 2 | ServiceSpec edits | Ports, selectors, type, traffic policies | + +Within each category, edits are applied in their registration order. Later features observe the Service as modified by +all previous features. + +## Editors + +### ServiceSpecEditor + +Controls service-level settings via `m.EditServiceSpec`. + +Available methods: `SetType`, `EnsurePort`, `RemovePort`, `SetSelector`, `EnsureSelector`, `RemoveSelector`, +`SetSessionAffinity`, `SetSessionAffinityConfig`, `SetPublishNotReadyAddresses`, `SetExternalTrafficPolicy`, +`SetInternalTrafficPolicy`, `SetLoadBalancerSourceRanges`, `SetExternalName`, `Raw`. + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.SetType(corev1.ServiceTypeLoadBalancer) + e.EnsurePort(corev1.ServicePort{ + Name: "https", + Port: 443, + TargetPort: intstr.FromInt32(8443), + }) + e.SetExternalTrafficPolicy(corev1.ServiceExternalTrafficPolicyLocal) + return nil +}) +``` + +#### Port Management + +`EnsurePort` upserts a port: if a port with the same `Name` exists, it is replaced; otherwise, when `Name` is empty, the +match is performed on the combination of `Port` and the effective `Protocol` (treating an empty protocol value as TCP). +This means TCP and UDP ports with the same port number are considered distinct unless you explicitly set matching +protocols. If no existing port matches, the new port is appended. `RemovePort` removes a port by name. + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + e.RemovePort("legacy") + return nil +}) +``` + +#### Selector Management + +`SetSelector` replaces the entire selector map. `EnsureSelector` adds or updates a single key-value pair. +`RemoveSelector` removes a single key. + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsureSelector("app", "myapp") + e.EnsureSelector("env", "production") + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.Raw().HealthCheckNodePort = 30000 + 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("service.beta.kubernetes.io/aws-load-balancer-type", "nlb") + return nil +}) +``` + +## Operational Status + +The Service primitive implements the `Operational` concept to track whether the Service is ready to accept traffic. + +### DefaultOperationalStatusHandler + +| Service Type | Behaviour | +| -------------- | ------------------------------------------------------------------------------------------------------------ | +| `LoadBalancer` | Reports `Pending` until `Status.LoadBalancer.Ingress` has entries with an IP or hostname; then `Operational` | +| `ClusterIP` | Immediately `Operational` | +| `NodePort` | Immediately `Operational` | +| `ExternalName` | Immediately `Operational` | +| Headless | Immediately `Operational` | + +Override with `WithCustomOperationalStatus` to add custom checks: + +```go +resource, err := service.NewBuilder(base). + WithCustomOperationalStatus(func(op concepts.ConvergingOperation, svc *corev1.Service) (concepts.OperationalStatusWithReason, error) { + // Custom logic, e.g. check for specific annotations + return service.DefaultOperationalStatusHandler(op, svc) + }). + Build() +``` + +## Suspension + +By default, Services are **unaffected** by suspension — they remain in the cluster when the parent component is +suspended. The default suspend mutation handler is a no-op, `DefaultDeleteOnSuspendHandler` returns `false`, and the +default suspension status handler reports `Suspended` immediately (no work required). + +This is appropriate for most use cases because Services are stateless routing objects that are safe to leave in place. + +Override with `WithCustomSuspendDeletionDecision` if you want to delete the Service on suspend: + +```go +resource, err := service.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *corev1.Service) bool { + return true // delete the Service during suspension + }). + Build() +``` + +You can also combine `WithCustomSuspendMutation` and `WithCustomSuspendStatus` for more advanced suspension behaviour, +such as modifying the Service before it is deleted or tracking external readiness before reporting suspended. + +## Data Extraction + +Use `WithDataExtractor` to read values from the reconciled Service, such as the assigned ClusterIP or LoadBalancer +ingress: + +```go +var assignedIP string + +resource, err := service.NewBuilder(base). + WithDataExtractor(func(svc corev1.Service) error { + assignedIP = svc.Spec.ClusterIP + return nil + }). + Build() +``` + +## Full Example: Feature-Composed Service + +```go +func BaseServiceMutation(version string) service.Mutation { + return service.Mutation{ + Name: "base-service", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{ + Name: "http", + Port: 80, + TargetPort: intstr.FromInt32(8080), + }) + return nil + }) + return nil + }, + } +} + +func MetricsPortMutation(version string, enabled bool) service.Mutation { + return service.Mutation{ + Name: "metrics-port", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{ + Name: "metrics", + Port: 9090, + TargetPort: intstr.FromInt32(9090), + }) + return nil + }) + return nil + }, + } +} + +resource, err := service.NewBuilder(base). + WithMutation(BaseServiceMutation(owner.Spec.Version)). + WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)). + Build() +``` + +When `EnableMetrics` is true, the Service will expose both the HTTP port and the metrics port. When false, only the HTTP +port is configured. 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. + +**Register mutations in dependency order.** If mutation B relies on a port added by mutation A, register A first. + +**Use `EnsurePort` for idempotent port management.** The mutator tracks ports by name (or port number when unnamed), so +repeated calls with the same name produce the same result. diff --git a/examples/service-primitive/README.md b/examples/service-primitive/README.md new file mode 100644 index 00000000..4f815490 --- /dev/null +++ b/examples/service-primitive/README.md @@ -0,0 +1,35 @@ +# Service Primitive Example + +This example demonstrates the usage of the `service` primitive within the operator component framework. It shows how to +manage a Kubernetes Service as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a Service with basic metadata, selector, and ports. +- **Feature Mutations**: Applying version-gated or conditional changes (additional ports, labels) using the `Mutator`. +- **Field Flavors**: Preserving annotations that might be managed by external tools (e.g., cloud load balancer + controllers). +- **Operational Status**: Tracking whether the Service is operational (relevant for LoadBalancer types). +- **Suspension**: Demonstrating that, by default, the Service remains present when the component is suspended + (`DeleteOnSuspend=false`), and how to opt into deletion if desired. +- **Data Extraction**: Harvesting information (ClusterIP, ports) from the reconciled resource. + +## 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 conditional metrics port. +- `resources/`: Contains the central `NewServiceResource` factory that assembles all features using `service.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/service-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 Service ports after each cycle. +4. Print the resulting status conditions. diff --git a/examples/service-primitive/app/controller.go b/examples/service-primitive/app/controller.go new file mode 100644 index 00000000..d903e715 --- /dev/null +++ b/examples/service-primitive/app/controller.go @@ -0,0 +1,55 @@ +// Package app provides a sample controller using the service 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 + + // NewServiceResource is a factory function to create the service resource. + // This allows us to inject the resource construction logic. + NewServiceResource 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 service resource for this owner. + svcResource, err := r.NewServiceResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the service. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(svcResource, 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/service-primitive/features/mutations.go b/examples/service-primitive/features/mutations.go new file mode 100644 index 00000000..e7de2640 --- /dev/null +++ b/examples/service-primitive/features/mutations.go @@ -0,0 +1,46 @@ +// Package features provides sample mutations for the service 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/service" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// VersionLabelMutation sets the app.kubernetes.io/version label on the Service. +// It is always enabled. +func VersionLabelMutation(version string) service.Mutation { + return service.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *service.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// MetricsPortMutation adds a metrics port to the Service when metrics are enabled. +func MetricsPortMutation(version string, enableMetrics bool) service.Mutation { + return service.Mutation{ + Name: "metrics-port", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *service.Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{ + Name: "metrics", + Port: 9090, + TargetPort: intstr.FromInt32(9090), + Protocol: corev1.ProtocolTCP, + }) + return nil + }) + return nil + }, + } +} diff --git a/examples/service-primitive/main.go b/examples/service-primitive/main.go new file mode 100644 index 00000000..68c01114 --- /dev/null +++ b/examples/service-primitive/main.go @@ -0,0 +1,115 @@ +// Package main is the entry point for the service 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/service-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/service-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + 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. + 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 := 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(&sharedapp.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.2.3", + EnableMetrics: true, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize 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, + }, + NewServiceResource: resources.NewServiceResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate + // how mutations compose service configuration. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableMetrics: false, // Disable metrics + Suspended: false, + }, + { + Version: "1.2.4", + EnableMetrics: false, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableMetrics, spec.Suspended) + + 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/service-primitive/resources/service.go b/examples/service-primitive/resources/service.go new file mode 100644 index 00000000..2f4b0f0c --- /dev/null +++ b/examples/service-primitive/resources/service.go @@ -0,0 +1,60 @@ +// Package resources provides resource implementations for the service primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/service-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/service" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// NewServiceResource constructs a service primitive resource with all the features. +func NewServiceResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base Service object. + base := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-service", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "app": owner.Name, + }, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt32(8080), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + // 2. Initialize the service builder. + builder := service.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Extract data from the reconciled Service. + builder.WithDataExtractor(func(svc corev1.Service) error { + fmt.Printf("Reconciled Service: %s (ClusterIP: %s)\n", svc.Name, svc.Spec.ClusterIP) + for _, port := range svc.Spec.Ports { + fmt.Printf(" Port: %s %d -> %s\n", port.Name, port.Port, port.TargetPort.String()) + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/servicespec.go b/pkg/mutation/editors/servicespec.go new file mode 100644 index 00000000..33740130 --- /dev/null +++ b/pkg/mutation/editors/servicespec.go @@ -0,0 +1,118 @@ +package editors + +import ( + corev1 "k8s.io/api/core/v1" +) + +// ServiceSpecEditor provides a typed API for mutating a Kubernetes ServiceSpec. +type ServiceSpecEditor struct { + spec *corev1.ServiceSpec +} + +// NewServiceSpecEditor creates a new ServiceSpecEditor for the given ServiceSpec. +func NewServiceSpecEditor(spec *corev1.ServiceSpec) *ServiceSpecEditor { + return &ServiceSpecEditor{spec: spec} +} + +// Raw returns the underlying *corev1.ServiceSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *ServiceSpecEditor) Raw() *corev1.ServiceSpec { + return e.spec +} + +// SetType sets the service type (ClusterIP, NodePort, LoadBalancer, ExternalName). +func (e *ServiceSpecEditor) SetType(t corev1.ServiceType) { + e.spec.Type = t +} + +// EnsurePort upserts a service port. If a port with the same Name exists (or the same +// Port number and Protocol when both ports are unnamed), it is replaced; otherwise the port +// is appended. When matching unnamed ports, only existing unnamed ports are considered, and +// an empty Protocol is treated as TCP (the Kubernetes default). +func (e *ServiceSpecEditor) EnsurePort(port corev1.ServicePort) { + for i, existing := range e.spec.Ports { + if port.Name != "" && existing.Name == port.Name { + e.spec.Ports[i] = port + return + } + if port.Name == "" && existing.Name == "" && existing.Port == port.Port && normalizeProtocol(existing.Protocol) == normalizeProtocol(port.Protocol) { + e.spec.Ports[i] = port + return + } + } + e.spec.Ports = append(e.spec.Ports, port) +} + +// normalizeProtocol returns the effective protocol, treating empty as TCP (the Kubernetes default). +func normalizeProtocol(p corev1.Protocol) corev1.Protocol { + if p == "" { + return corev1.ProtocolTCP + } + return p +} + +// RemovePort removes a service port by name. It is a no-op if the port does not exist. +func (e *ServiceSpecEditor) RemovePort(name string) { + for i, existing := range e.spec.Ports { + if existing.Name == name { + e.spec.Ports = append(e.spec.Ports[:i], e.spec.Ports[i+1:]...) + return + } + } +} + +// SetSelector replaces the entire service selector map. +func (e *ServiceSpecEditor) SetSelector(selector map[string]string) { + e.spec.Selector = selector +} + +// EnsureSelector adds or updates a single selector key-value pair. +func (e *ServiceSpecEditor) EnsureSelector(key, value string) { + if e.spec.Selector == nil { + e.spec.Selector = make(map[string]string) + } + e.spec.Selector[key] = value +} + +// RemoveSelector removes a single selector key. It is a no-op if the key does not exist. +func (e *ServiceSpecEditor) RemoveSelector(key string) { + if e.spec.Selector != nil { + delete(e.spec.Selector, key) + } +} + +// SetSessionAffinity sets the session affinity type (None or ClientIP). +func (e *ServiceSpecEditor) SetSessionAffinity(affinity corev1.ServiceAffinity) { + e.spec.SessionAffinity = affinity +} + +// SetSessionAffinityConfig sets the session affinity configuration. +func (e *ServiceSpecEditor) SetSessionAffinityConfig(cfg *corev1.SessionAffinityConfig) { + e.spec.SessionAffinityConfig = cfg +} + +// SetPublishNotReadyAddresses controls whether endpoints for not-ready pods are published. +func (e *ServiceSpecEditor) SetPublishNotReadyAddresses(v bool) { + e.spec.PublishNotReadyAddresses = v +} + +// SetExternalTrafficPolicy sets the external traffic policy for the service. +func (e *ServiceSpecEditor) SetExternalTrafficPolicy(policy corev1.ServiceExternalTrafficPolicy) { + e.spec.ExternalTrafficPolicy = policy +} + +// SetInternalTrafficPolicy sets the internal traffic policy for the service. +func (e *ServiceSpecEditor) SetInternalTrafficPolicy(policy *corev1.ServiceInternalTrafficPolicy) { + e.spec.InternalTrafficPolicy = policy +} + +// SetLoadBalancerSourceRanges sets the allowed source ranges for a LoadBalancer service. +func (e *ServiceSpecEditor) SetLoadBalancerSourceRanges(ranges []string) { + e.spec.LoadBalancerSourceRanges = ranges +} + +// SetExternalName sets the external name for an ExternalName service type. +func (e *ServiceSpecEditor) SetExternalName(name string) { + e.spec.ExternalName = name +} diff --git a/pkg/mutation/editors/servicespec_test.go b/pkg/mutation/editors/servicespec_test.go new file mode 100644 index 00000000..3d380c88 --- /dev/null +++ b/pkg/mutation/editors/servicespec_test.go @@ -0,0 +1,188 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestServiceSpecEditor(t *testing.T) { + t.Run("SetType", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.SetType(corev1.ServiceTypeLoadBalancer) + assert.Equal(t, corev1.ServiceTypeLoadBalancer, spec.Type) + }) + + t.Run("EnsurePort appends new port", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + assert.Len(t, spec.Ports, 1) + assert.Equal(t, "http", spec.Ports[0].Name) + assert.Equal(t, int32(80), spec.Ports[0].Port) + }) + + t.Run("EnsurePort upserts by name", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80}, + }, + } + editor := NewServiceSpecEditor(spec) + editor.EnsurePort(corev1.ServicePort{Name: "http", Port: 8080, TargetPort: intstr.FromInt32(8080)}) + assert.Len(t, spec.Ports, 1) + assert.Equal(t, int32(8080), spec.Ports[0].Port) + }) + + t.Run("EnsurePort upserts by port number when name is empty", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Port: 80}, + }, + } + editor := NewServiceSpecEditor(spec) + editor.EnsurePort(corev1.ServicePort{Port: 80, TargetPort: intstr.FromInt32(8080)}) + assert.Len(t, spec.Ports, 1) + assert.Equal(t, intstr.FromInt32(8080), spec.Ports[0].TargetPort) + }) + + t.Run("EnsurePort appends when no match", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80}, + }, + } + editor := NewServiceSpecEditor(spec) + editor.EnsurePort(corev1.ServicePort{Name: "https", Port: 443}) + assert.Len(t, spec.Ports, 2) + }) + + t.Run("RemovePort removes existing", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80}, + {Name: "https", Port: 443}, + }, + } + editor := NewServiceSpecEditor(spec) + editor.RemovePort("http") + assert.Len(t, spec.Ports, 1) + assert.Equal(t, "https", spec.Ports[0].Name) + }) + + t.Run("RemovePort no-op when absent", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80}, + }, + } + editor := NewServiceSpecEditor(spec) + editor.RemovePort("missing") + assert.Len(t, spec.Ports, 1) + }) + + t.Run("SetSelector replaces entire selector", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Selector: map[string]string{"old": "value"}, + } + editor := NewServiceSpecEditor(spec) + editor.SetSelector(map[string]string{"app": "test"}) + assert.Equal(t, map[string]string{"app": "test"}, spec.Selector) + }) + + t.Run("EnsureSelector adds key", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.EnsureSelector("app", "test") + assert.Equal(t, "test", spec.Selector["app"]) + }) + + t.Run("EnsureSelector overwrites key", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Selector: map[string]string{"app": "old"}, + } + editor := NewServiceSpecEditor(spec) + editor.EnsureSelector("app", "new") + assert.Equal(t, "new", spec.Selector["app"]) + }) + + t.Run("RemoveSelector removes key", func(t *testing.T) { + spec := &corev1.ServiceSpec{ + Selector: map[string]string{"app": "test", "env": "prod"}, + } + editor := NewServiceSpecEditor(spec) + editor.RemoveSelector("app") + assert.NotContains(t, spec.Selector, "app") + assert.Equal(t, "prod", spec.Selector["env"]) + }) + + t.Run("RemoveSelector no-op on nil selector", func(_ *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.RemoveSelector("missing") // should not panic + }) + + t.Run("SetSessionAffinity", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.SetSessionAffinity(corev1.ServiceAffinityClientIP) + assert.Equal(t, corev1.ServiceAffinityClientIP, spec.SessionAffinity) + }) + + t.Run("SetSessionAffinityConfig", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + timeout := int32(10800) + cfg := &corev1.SessionAffinityConfig{ + ClientIP: &corev1.ClientIPConfig{TimeoutSeconds: &timeout}, + } + editor.SetSessionAffinityConfig(cfg) + assert.Equal(t, cfg, spec.SessionAffinityConfig) + }) + + t.Run("SetPublishNotReadyAddresses", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.SetPublishNotReadyAddresses(true) + assert.True(t, spec.PublishNotReadyAddresses) + }) + + t.Run("SetExternalTrafficPolicy", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.SetExternalTrafficPolicy(corev1.ServiceExternalTrafficPolicyLocal) + assert.Equal(t, corev1.ServiceExternalTrafficPolicyLocal, spec.ExternalTrafficPolicy) + }) + + t.Run("SetInternalTrafficPolicy", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + local := corev1.ServiceInternalTrafficPolicyLocal + editor.SetInternalTrafficPolicy(&local) + assert.Equal(t, &local, spec.InternalTrafficPolicy) + }) + + t.Run("SetLoadBalancerSourceRanges", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + ranges := []string{"10.0.0.0/8", "192.168.0.0/16"} + editor.SetLoadBalancerSourceRanges(ranges) + assert.Equal(t, ranges, spec.LoadBalancerSourceRanges) + }) + + t.Run("SetExternalName", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + editor.SetExternalName("my.external.service.example.com") + assert.Equal(t, "my.external.service.example.com", spec.ExternalName) + }) + + t.Run("Raw returns underlying spec", func(t *testing.T) { + spec := &corev1.ServiceSpec{} + editor := NewServiceSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/primitives/service/builder.go b/pkg/primitives/service/builder.go new file mode 100644 index 00000000..79166fd5 --- /dev/null +++ b/pkg/primitives/service/builder.go @@ -0,0 +1,155 @@ +package service + +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 Service Resource. +// +// It provides a fluent API for registering mutations, status handlers, and +// data extractors. This builder ensures that the resulting Resource is +// properly initialized and validated before use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*corev1.Service, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Service object. +// +// The Service 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 Service must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(svc *corev1.Service) *Builder { + identityFunc := func(s *corev1.Service) string { + return fmt.Sprintf("v1/Service/%s/%s", s.Namespace, s.Name) + } + + base := generic.NewIntegrationBuilder[*corev1.Service, *Mutator]( + svc, + identityFunc, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the Service. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// They are typically used by Features to modify ports, selectors, or other +// service configuration. +// +// 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 +} + +// WithCustomOperationalStatus overrides the default logic for determining if the +// Service is operational. +// +// The default behavior uses DefaultOperationalStatusHandler, which considers +// LoadBalancer services pending until an ingress IP or hostname is assigned, +// and all other service types immediately operational. +// +// If you want to augment the default behavior, you can call DefaultOperationalStatusHandler +// within your custom handler. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *corev1.Service) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which always reports +// Suspended because the default behaviour leaves the Service untouched. +// +// If you want to augment the default behavior, you can call DefaultSuspensionStatusHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*corev1.Service) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Service should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op +// because the default behaviour leaves the Service unaffected by suspension. +// +// 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 Service when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns false +// because Services are stateless routing objects that are safe to leave in place. +// +// If you want to augment the default behavior, you can call DefaultDeleteOnSuspendHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*corev1.Service) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// Service after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields (like assigned ClusterIP +// or LoadBalancer ingress) and making them available to other components or +// resources via the framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(corev1.Service) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(s *corev1.Service) error { + return extractor(*s) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base Service object was provided. +// - The Service 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/service/builder_test.go b/pkg/primitives/service/builder_test.go new file mode 100644 index 00000000..641d436d --- /dev/null +++ b/pkg/primitives/service/builder_test.go @@ -0,0 +1,232 @@ +package service + +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 + svc *corev1.Service + expectedErr string + }{ + { + name: "nil service", + svc: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid service", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.svc).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/Service/test-ns/test-svc", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(svc). + WithMutation(m). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) + }) + + t.Run("WithCustomOperationalStatus", func(t *testing.T) { + t.Parallel() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *corev1.Service) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusPending}, nil + } + res, err := NewBuilder(svc). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Service) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(svc). + 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() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(svc). + 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() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Service) bool { + return false + } + res, err := NewBuilder(svc). + 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() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ corev1.Service) error { + called = true + return nil + } + res, err := NewBuilder(svc). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&corev1.Service{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(svc). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) + + t.Run("WithDataExtractor error propagated", func(t *testing.T) { + t.Parallel() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(svc). + WithDataExtractor(func(_ corev1.Service) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&corev1.Service{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") + }) +} diff --git a/pkg/primitives/service/handlers.go b/pkg/primitives/service/handlers.go new file mode 100644 index 00000000..0ec86434 --- /dev/null +++ b/pkg/primitives/service/handlers.go @@ -0,0 +1,87 @@ +package service + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining if a Service is operational. +// +// For LoadBalancer services, it reports OperationalStatusPending until at least one ingress +// entry (IP or hostname) is assigned to Status.LoadBalancer.Ingress, then reports +// OperationalStatusOperational. +// +// For all other service types (ClusterIP, NodePort, ExternalName, headless), it +// immediately reports OperationalStatusOperational. +// +// This function is used as the default handler by the Resource if no custom handler is +// registered via Builder.WithCustomOperationalStatus. It can be reused within custom +// handlers to augment the default behavior. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, svc *corev1.Service, +) (concepts.OperationalStatusWithReason, error) { + if svc.Spec.Type == corev1.ServiceTypeLoadBalancer { + hasIngressAddress := false + for _, ing := range svc.Status.LoadBalancer.Ingress { + if ing.IP != "" || ing.Hostname != "" { + hasIngressAddress = true + break + } + } + + if !hasIngressAddress { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Awaiting load balancer IP/hostname assignment", + }, nil + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Load balancer IP/hostname assigned", + }, nil + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Service is operational", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the Service +// when the parent component is suspended. +// +// It always returns false, leaving the Service untouched during suspension. Services are +// typically stateless routing objects that are safe to leave in place. Override with +// Builder.WithCustomSuspendDeletionDecision to delete the Service on suspend. +// +// 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.Service) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a Service when the +// component is suspended. +// +// It is a no-op because the default behaviour leaves the Service unaffected by suspension. +// +// 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 reports the progress of the suspension process. +// +// It always reports Suspended because the default behaviour is a no-op — the Service +// is left in place and requires no work to reach the suspended state. +// +// 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.Service) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Service unaffected by suspension", + }, nil +} diff --git a/pkg/primitives/service/handlers_test.go b/pkg/primitives/service/handlers_test.go new file mode 100644 index 00000000..16d734a7 --- /dev/null +++ b/pkg/primitives/service/handlers_test.go @@ -0,0 +1,166 @@ +package service + +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 TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "ClusterIP is immediately operational", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Service is operational", + }, + { + name: "NodePort is immediately operational", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Service is operational", + }, + { + name: "ExternalName is immediately operational", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Service is operational", + }, + { + name: "service with default type (empty spec) is immediately operational", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{}, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Service is operational", + }, + { + name: "LoadBalancer pending (no ingress)", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{}, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer IP/hostname assignment", + }, + { + name: "LoadBalancer pending (empty ingress slice)", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{}, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer IP/hostname assignment", + }, + { + name: "LoadBalancer pending (ingress entry without IP or hostname)", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer IP/hostname assignment", + }, + { + name: "LoadBalancer operational (IP assigned)", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {IP: "203.0.113.10"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Load balancer IP/hostname assigned", + }, + { + name: "LoadBalancer operational (hostname assigned)", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {Hostname: "abc123.elb.amazonaws.com"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Load balancer IP/hostname assigned", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationNone, tt.svc) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + svc := &corev1.Service{} + assert.False(t, DefaultDeleteOnSuspendHandler(svc)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + svc := &corev1.Service{} + mutator := NewMutator(svc) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + // No-op, so service should be unchanged + require.NoError(t, mutator.Apply()) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + svc := &corev1.Service{} + got, err := DefaultSuspensionStatusHandler(svc) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "Service unaffected by suspension", got.Reason) +} diff --git a/pkg/primitives/service/mutator.go b/pkg/primitives/service/mutator.go new file mode 100644 index 00000000..722da976 --- /dev/null +++ b/pkg/primitives/service/mutator.go @@ -0,0 +1,129 @@ +package service + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a service Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + serviceSpecEdits []func(*editors.ServiceSpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes Service. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Service 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. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + svc *corev1.Service + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Service. +// BeginFeature must be called before registering any mutations. +func NewMutator(svc *corev1.Service) *Mutator { + return &Mutator{ + svc: svc, + } +} + +// BeginFeature starts a new feature planning scope. +// +// This method is intended for use by the generic feature/mutation framework to +// delineate feature boundaries. All subsequent mutation registrations will be +// grouped into a new feature plan and applied after any previously planned +// features. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Service's own metadata. +// +// Metadata edits are applied before service spec edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditServiceSpec records a mutation for the Service's spec. +// +// The editor provides structured operations (SetType, EnsurePort, SetSelector, +// etc.) as well as Raw() for free-form access. Service spec edits are applied +// after metadata edits within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditServiceSpec(edit func(*editors.ServiceSpecEditor) error) { + if edit == nil { + return + } + m.active.serviceSpecEdits = append(m.active.serviceSpecEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying Service. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Service spec edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Service as modified by all previous features. +// +// After all edits are applied, auto-allocated NodePort values that were present +// before mutations are restored for NodePort and LoadBalancer Services when the +// resulting port's NodePort is 0 (unset). This prevents mutations that replace +// ports via EnsurePort from unintentionally clearing server-assigned NodePorts. +func (m *Mutator) Apply() error { + // Snapshot ports before mutations so we can restore auto-allocated + // NodePorts that mutations may inadvertently clear. + snapshot := m.svc.DeepCopy() + + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.svc.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Service spec edits + if len(plan.serviceSpecEdits) > 0 { + editor := editors.NewServiceSpecEditor(&m.svc.Spec) + for _, edit := range plan.serviceSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + // Restore auto-allocated NodePorts for types that support them. + effectiveType := m.svc.Spec.Type + if effectiveType == "" { + effectiveType = corev1.ServiceTypeClusterIP + } + if effectiveType == corev1.ServiceTypeNodePort || effectiveType == corev1.ServiceTypeLoadBalancer { + preserveNodePorts(m.svc, snapshot) + } + + return nil +} diff --git a/pkg/primitives/service/mutator_test.go b/pkg/primitives/service/mutator_test.go new file mode 100644 index 00000000..349da87a --- /dev/null +++ b/pkg/primitives/service/mutator_test.go @@ -0,0 +1,341 @@ +package service + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "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 newTestService() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "default", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", svc.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditObjectMetadata_Error(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return errors.New("metadata error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "metadata error") +} + +// --- EditServiceSpec --- + +func TestMutator_EditServiceSpec(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.SetType(corev1.ServiceTypeNodePort) + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, corev1.ServiceTypeNodePort, svc.Spec.Type) +} + +func TestMutator_EditServiceSpec_Nil(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditServiceSpec_Error(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(_ *editors.ServiceSpecEditor) error { + return errors.New("spec error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "spec error") +} + +func TestMutator_EditServiceSpec_Ports(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + e.EnsurePort(corev1.ServicePort{Name: "https", Port: 443}) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, svc.Spec.Ports, 2) +} + +func TestMutator_EditServiceSpec_Selector(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsureSelector("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", svc.Spec.Selector["app"]) +} + +func TestMutator_EditServiceSpec_RawAccess(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.Raw().ExternalName = "external.example.com" + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "external.example.com", svc.Spec.ExternalName) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before service spec edits. + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", svc.Labels["order"]) + assert.Len(t, svc.Spec.Ports, 1) + assert.Equal(t, "http", svc.Spec.Ports[0].Name) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "https", Port: 443}) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Len(t, svc.Spec.Ports, 2) +} + +func TestMutator_MultipleFeatures_LaterSeesEarlier(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsureSelector("app", "myapp") + return nil + }) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + // Later feature should see the selector set by the earlier feature. + e.EnsureSelector("env", "prod") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "myapp", svc.Spec.Selector["app"]) + assert.Equal(t, "prod", svc.Spec.Selector["env"]) +} + +// --- NodePort preservation across mutations --- + +func TestMutator_Apply_PreservesNodePortsAfterEnsurePort(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "default"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80, NodePort: 30080}, + {Name: "https", Port: 443, NodePort: 30443}, + }, + }, + } + m := NewMutator(svc) + m.BeginFeature() + // EnsurePort replaces the entire ServicePort, which zeroes NodePort. + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, int32(30080), svc.Spec.Ports[0].NodePort, "auto-allocated NodePort should be restored") + assert.Equal(t, int32(30443), svc.Spec.Ports[1].NodePort, "untouched port should keep its NodePort") +} + +func TestMutator_Apply_PreservesNodePortsForLoadBalancer(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "default"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80, NodePort: 31080}, + }, + }, + } + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, int32(31080), svc.Spec.Ports[0].NodePort) +} + +func TestMutator_Apply_ExplicitNodePortNotOverridden(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "default"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80, NodePort: 30080}, + }, + }, + } + m := NewMutator(svc) + m.BeginFeature() + // Explicitly set a different NodePort — should not be reverted to the snapshot. + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80, NodePort: 32000}) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, int32(32000), svc.Spec.Ports[0].NodePort, "explicitly set NodePort should not be overridden") +} + +func TestMutator_Apply_NoNodePortPreservationForClusterIP(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "default"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80, NodePort: 30080}, + }, + }, + } + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, int32(0), svc.Spec.Ports[0].NodePort, "ClusterIP services should not preserve NodePorts") +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + + 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) { + svc := newTestService() + m := NewMutator(svc) + + 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) { + svc := newTestService() + m := NewMutator(svc) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "https", Port: 443}) + return nil + }) + + assert.Len(t, m.plans[0].serviceSpecEdits, 1, "first plan should have one spec edit") + assert.Len(t, m.plans[1].serviceSpecEdits, 1, "second plan should have one spec edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + svc := newTestService() + m := NewMutator(svc) + m.BeginFeature() + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80}) + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, svc.Spec.Ports, 1) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/service/resource.go b/pkg/primitives/service/resource.go new file mode 100644 index 00000000..82d17d20 --- /dev/null +++ b/pkg/primitives/service/resource.go @@ -0,0 +1,126 @@ +// Package service provides a builder and resource for managing Kubernetes Services. +package service + +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" +) + +// preserveNodePorts restores auto-allocated nodePort values from the original +// object when the desired port's NodePort is 0. Ports are matched by Name when +// both ports have a non-empty Name; otherwise they are matched by Port+Protocol +// (treating empty protocol as TCP). +func preserveNodePorts(current, original *corev1.Service) { + if len(original.Spec.Ports) == 0 { + return + } + + for i := range current.Spec.Ports { + if current.Spec.Ports[i].NodePort != 0 { + continue // explicitly set, don't override + } + for _, orig := range original.Spec.Ports { + if orig.NodePort == 0 { + continue + } + if matchPort(current.Spec.Ports[i], orig) { + current.Spec.Ports[i].NodePort = orig.NodePort + break + } + } + } +} + +func matchPort(a, b corev1.ServicePort) bool { + if a.Name != "" && b.Name != "" { + return a.Name == b.Name + } + return a.Port == b.Port && normalizeProtocol(a.Protocol) == normalizeProtocol(b.Protocol) +} + +func normalizeProtocol(p corev1.Protocol) corev1.Protocol { + if p == "" { + return corev1.ProtocolTCP + } + return p +} + +// Resource is a high-level abstraction for managing a Kubernetes Service within +// a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.Operational: for tracking whether the Service is operational. +// - component.Suspendable: for participating in the component suspension lifecycle. +// - component.DataExtractable: for exporting values after successful reconciliation. +type Resource struct { + base *generic.IntegrationResource[*corev1.Service, *Mutator] +} + +// Identity returns a unique identifier for the Service in the format +// "v1/Service//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes Service 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 Service into the desired state. +// +// The mutation process follows this order: +// 1. Feature mutations: all registered feature-gated mutations are applied in order. +// 2. Suspension: if the resource is in a suspending state, the suspension mutation is applied. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus reports the operational status of the Service. +// +// By default, it uses DefaultOperationalStatusHandler, which considers LoadBalancer +// services pending until an ingress IP or hostname is assigned, and all other service +// types immediately operational. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the Service should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false — the +// Service is left in place during suspension. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the suspension of the Service. +// +// The default handler is a no-op — the Service is left unaffected by suspension. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus reports the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which always reports +// Suspended because the default behaviour leaves the Service untouched. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled Service. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values such as assigned ClusterIP or +// LoadBalancer ingress from the Service. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/service/resource_test.go b/pkg/primitives/service/resource_test.go new file mode 100644 index 00000000..10ef3668 --- /dev/null +++ b/pkg/primitives/service/resource_test.go @@ -0,0 +1,199 @@ +package service + +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/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidService() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-svc", + Namespace: "test-ns", + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "test"}, + Ports: []corev1.ServicePort{ + {Name: "http", Port: 80}, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidService()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/Service/test-ns/test-svc", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + svc := newValidService() + res, err := NewBuilder(svc).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.Service) + require.True(t, ok) + assert.Equal(t, svc.Name, got.Name) + assert.Equal(t, svc.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-svc", svc.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidService() + 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.(*corev1.Service) + assert.Equal(t, "test", got.Spec.Selector["app"]) + assert.Equal(t, int32(80), got.Spec.Ports[0].Port) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidService() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-port", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "metrics", Port: 9090}) + 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.Service) + assert.Equal(t, int32(80), got.Spec.Ports[0].Port) + require.Len(t, got.Spec.Ports, 2) + assert.Equal(t, "metrics", got.Spec.Ports[1].Name) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidService() + + var callOrder []string + observedFirstBeforeSecond := false + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "first-mutation", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + callOrder = append(callOrder, "first") + m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { + e.EnsurePort(corev1.ServicePort{Name: "first", Port: 1000}) + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "second-mutation", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(_ *Mutator) error { + if len(callOrder) == 1 && callOrder[0] == "first" { + observedFirstBeforeSecond = true + } + callOrder = append(callOrder, "second") + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + assert.Equal(t, []string{"first", "second"}, callOrder) + assert.True(t, observedFirstBeforeSecond, "second mutation function should execute after the first") +} + +func TestResource_ConvergingStatus(t *testing.T) { + svc := newValidService() + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + + res, err := NewBuilder(svc).Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationNone) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) +} + +func TestResource_Suspend(t *testing.T) { + res, err := NewBuilder(newValidService()).Build() + require.NoError(t, err) + + require.NoError(t, res.Suspend()) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + res, err := NewBuilder(newValidService()).Build() + require.NoError(t, err) + + assert.False(t, res.DeleteOnSuspend()) +} + +func TestResource_SuspensionStatus(t *testing.T) { + res, err := NewBuilder(newValidService()).Build() + require.NoError(t, err) + + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) +} + +func TestResource_ExtractData(t *testing.T) { + svc := newValidService() + + var extracted string + res, err := NewBuilder(svc). + WithDataExtractor(func(s corev1.Service) error { + extracted = s.Spec.Selector["app"] + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "test", extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidService()). + WithDataExtractor(func(_ corev1.Service) 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") +}