diff --git a/docs/primitives.md b/docs/primitives.md index ef0359d9..08431c8f 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -123,6 +123,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `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 @@ -153,6 +154,8 @@ have been applied. This means a single mutation can safely add a container and t | `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | | `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) | | `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) | +| `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) | + ## Usage Examples diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md new file mode 100644 index 00000000..d3b8f1d2 --- /dev/null +++ b/docs/primitives/ingress.md @@ -0,0 +1,270 @@ +# Ingress Primitive + +The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. +It integrates with the component lifecycle and provides a structured mutation API for managing rules, TLS configuration, +and metadata. For an overview of all built-in primitives, see [Primitives](../primitives.md). + +## Capabilities + +| Capability | Detail | +| ---------------------- | ---------------------------------------------------------------------------------------------- | +| **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | +| **Suspension** | No-op by default — Ingress is left in place; backend returns 502/503 | +| **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | + +## Building an Ingress Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/ingress" + +base := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "web-ingress", + Namespace: owner.Namespace, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +resource, err := ingress.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying an `Ingress` 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) ingress.Mutation { + return ingress.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *ingress.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 TLSMutation(version string, enabled bool) ingress.Mutation { + return ingress.Mutation{ + Name: "tls", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + 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 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 `Ingress` object | +| 2 | Ingress spec edits | Ingress class, default backend, rules, TLS via editor | + +Within each category, edits are applied in their registration order. Later features observe the Ingress as modified by +all previous features. + +## Editors + +### IngressSpecEditor + +The primary API for modifying the Ingress spec. Use `m.EditIngressSpec` for full control: + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + return nil +}) +``` + +#### SetIngressClassName + +Sets the `spec.ingressClassName` field. + +#### SetDefaultBackend + +Sets the default backend for traffic that does not match any rule. + +#### EnsureRule and RemoveRule + +`EnsureRule` upserts a rule by `Host` — if a rule with the same host already exists, it is replaced. `RemoveRule` +deletes the rule with the given host; it is a no-op if no matching rule exists. + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{ + Host: "api.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/v1", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-svc", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + }, + }, + }, + }, + }, + }) + e.RemoveRule("deprecated.example.com") + return nil +}) +``` + +#### EnsureTLS and RemoveTLS + +`EnsureTLS` upserts a TLS entry by the first host in the `Hosts` slice. `RemoveTLS` removes TLS entries whose first host +matches any of the provided hosts. + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com", "www.example.com"}, + SecretName: "wildcard-tls", + }) + e.RemoveTLS("old.example.com") + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access when the typed API is insufficient: + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + spec := e.Raw() + // direct manipulation + 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.EnsureAnnotation("nginx.ingress.kubernetes.io/rewrite-target", "/") + return nil +}) +``` + +## Operational Status + +The Ingress primitive uses the **Integration** lifecycle, which implements `concepts.Operational` instead of +`concepts.Alive`. + +### DefaultOperationalStatusHandler + +| Condition | Status | Reason | +| ----------------------------------------- | ------------------ | ----------------------------------------- | +| Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address | +| Otherwise | `OperationPending` | Awaiting load balancer address assignment | + +The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or +`Hostname` to report operational. + +Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by +cloud providers). + +## Suspension + +### Default Behaviour + +The default suspension strategy is a **no-op**: + +- `DefaultDeleteOnSuspendHandler` returns `false` — the Ingress is not deleted. +- `DefaultSuspendMutationHandler` does nothing — the Ingress spec is not modified. +- `DefaultSuspensionStatusHandler` immediately reports `Suspended` with reason + `"Ingress suspended (backend unavailable)"`. + +**Rationale**: deleting an Ingress causes the ingress controller (e.g. nginx) to reload its configuration, which affects +the entire cluster's routing — not just the suspended service. When the backend service is suspended, the Ingress +returning 502/503 is the correct observable behaviour. + +### Custom Suspension + +Override any of the suspension handlers via the builder: + +```go +resource, err := ingress.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *networkingv1.Ingress) bool { + return true // delete on suspend + }). + Build() +``` + +## 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 rule added by mutation A, register A first. + +**Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override +to delete-on-suspend if your use case specifically requires removing the Ingress from the cluster during suspension. diff --git a/examples/ingress-primitive/README.md b/examples/ingress-primitive/README.md new file mode 100644 index 00000000..2a09b6f5 --- /dev/null +++ b/examples/ingress-primitive/README.md @@ -0,0 +1,32 @@ +# Ingress Primitive Example + +This example demonstrates the usage of the `ingress` primitive within the operator component framework. It shows how to +manage a Kubernetes Ingress as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing an Ingress with rules, TLS, and ingress class. +- **Feature Mutations**: Applying conditional changes (TLS configuration, version annotations) using the `Mutator`. +- **Data Extraction**: Harvesting information from the reconciled resource. + +## Directory Structure + +- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. +- `features/`: Contains modular feature definitions: + - `mutations.go`: version annotation and TLS configuration mutations. +- `resources/`: Contains the central `NewIngressResource` factory that assembles all features using the + `ingress.Builder`. +- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/ingress-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components through several spec changes (version upgrade, TLS toggle, suspension). +4. Print the resulting status conditions and Ingress resource state. diff --git a/examples/ingress-primitive/app/controller.go b/examples/ingress-primitive/app/controller.go new file mode 100644 index 00000000..c3c53c96 --- /dev/null +++ b/examples/ingress-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the ingress primitive. +package app + +import ( + "context" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewIngressResource is a factory function to create the ingress resource. + // This allows us to inject the resource construction logic. + NewIngressResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + // 1. Build the ingress resource for this owner. + ingressResource, err := r.NewIngressResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the ingress. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(ingressResource, 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/ingress-primitive/app/owner.go b/examples/ingress-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/ingress-primitive/app/owner.go @@ -0,0 +1,20 @@ +package app + +import ( + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme diff --git a/examples/ingress-primitive/features/mutations.go b/examples/ingress-primitive/features/mutations.go new file mode 100644 index 00000000..84c86326 --- /dev/null +++ b/examples/ingress-primitive/features/mutations.go @@ -0,0 +1,46 @@ +// Package features provides modular feature mutations for the ingress 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/ingress" + networkingv1 "k8s.io/api/networking/v1" +) + +// VersionAnnotation sets a version annotation on the Ingress metadata. +func VersionAnnotation(version string) ingress.Mutation { + return ingress.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *ingress.Mutator) error { + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// TLSFeature adds a TLS entry when enabled. +func TLSFeature(enabled bool, appName string) ingress.Mutation { + return ingress.Mutation{ + Name: "TLS", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{appName + ".example.com"}, + SecretName: appName + "-tls", + }) + return nil + }) + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("cert-manager.io/cluster-issuer", "letsencrypt-prod") + return nil + }) + return nil + }, + } +} diff --git a/examples/ingress-primitive/main.go b/examples/ingress-primitive/main.go new file mode 100644 index 00000000..bb35a72c --- /dev/null +++ b/examples/ingress-primitive/main.go @@ -0,0 +1,118 @@ +// Package main is the entry point for the ingress 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/ingress-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/resources" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client for the example. + scheme := runtime.NewScheme() + if err := app.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := networkingv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add networking/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, // Used as TLS toggle in this example + EnableMetrics: false, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + // Pass the ingress resource factory. + NewIngressResource: resources.NewIngressResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, // TLS enabled + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable TLS + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, TLS=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/ingress-primitive/resources/ingress.go b/examples/ingress-primitive/resources/ingress.go new file mode 100644 index 00000000..d0bc8e53 --- /dev/null +++ b/examples/ingress-primitive/resources/ingress.go @@ -0,0 +1,76 @@ +// Package resources provides resource implementations for the ingress primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/ingress" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" +) + +// NewIngressResource constructs an ingress primitive resource with all the features. +func NewIngressResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base ingress object. + base := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-ingress", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: owner.Name + ".example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: owner.Name + "-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // 2. Initialize the ingress builder. + builder := ingress.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.VersionAnnotation(owner.Spec.Version)) + builder.WithMutation(features.TLSFeature(owner.Spec.EnableTracing, owner.Name)) + + // 4. Data extraction. + builder.WithDataExtractor(func(ing networkingv1.Ingress) error { + fmt.Printf("Reconciling ingress: %s\n", ing.Name) + + y, err := yaml.Marshal(ing) + if err != nil { + return fmt.Errorf("failed to marshal ingress to yaml: %w", err) + } + fmt.Printf("Complete Ingress Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/ingressspec.go b/pkg/mutation/editors/ingressspec.go new file mode 100644 index 00000000..3ad98a05 --- /dev/null +++ b/pkg/mutation/editors/ingressspec.go @@ -0,0 +1,91 @@ +package editors + +import ( + networkingv1 "k8s.io/api/networking/v1" +) + +// IngressSpecEditor provides a typed API for mutating a Kubernetes IngressSpec. +type IngressSpecEditor struct { + spec *networkingv1.IngressSpec +} + +// NewIngressSpecEditor creates a new IngressSpecEditor for the given IngressSpec. +func NewIngressSpecEditor(spec *networkingv1.IngressSpec) *IngressSpecEditor { + return &IngressSpecEditor{spec: spec} +} + +// Raw returns the underlying *networkingv1.IngressSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *IngressSpecEditor) Raw() *networkingv1.IngressSpec { + return e.spec +} + +// SetIngressClassName sets the IngressClassName field of the IngressSpec. +func (e *IngressSpecEditor) SetIngressClassName(name string) { + e.spec.IngressClassName = &name +} + +// SetDefaultBackend sets the default backend for the Ingress. +func (e *IngressSpecEditor) SetDefaultBackend(backend *networkingv1.IngressBackend) { + e.spec.DefaultBackend = backend +} + +// EnsureRule upserts a rule by Host. If a rule with the same Host already exists, +// it is replaced. Otherwise the rule is appended. +func (e *IngressSpecEditor) EnsureRule(rule networkingv1.IngressRule) { + for i, existing := range e.spec.Rules { + if existing.Host == rule.Host { + e.spec.Rules[i] = rule + return + } + } + e.spec.Rules = append(e.spec.Rules, rule) +} + +// RemoveRule removes the rule with the given host. It is a no-op if no rule +// with that host exists. +func (e *IngressSpecEditor) RemoveRule(host string) { + for i, existing := range e.spec.Rules { + if existing.Host == host { + e.spec.Rules = append(e.spec.Rules[:i], e.spec.Rules[i+1:]...) + return + } + } +} + +// EnsureTLS upserts a TLS entry by the first host in the Hosts slice. If a TLS +// entry with the same first host already exists, it is replaced. Otherwise the +// entry is appended. +func (e *IngressSpecEditor) EnsureTLS(tls networkingv1.IngressTLS) { + if len(tls.Hosts) == 0 { + return + } + key := tls.Hosts[0] + for i, existing := range e.spec.TLS { + if len(existing.Hosts) > 0 && existing.Hosts[0] == key { + e.spec.TLS[i] = tls + return + } + } + e.spec.TLS = append(e.spec.TLS, tls) +} + +// RemoveTLS removes TLS entries whose first host matches any of the provided hosts. +// It is a no-op for hosts that do not match any existing TLS entry. +func (e *IngressSpecEditor) RemoveTLS(hosts ...string) { + hostSet := make(map[string]struct{}, len(hosts)) + for _, h := range hosts { + hostSet[h] = struct{}{} + } + filtered := e.spec.TLS[:0] + for _, tls := range e.spec.TLS { + if len(tls.Hosts) > 0 { + if _, match := hostSet[tls.Hosts[0]]; match { + continue + } + } + filtered = append(filtered, tls) + } + e.spec.TLS = filtered +} diff --git a/pkg/mutation/editors/ingressspec_test.go b/pkg/mutation/editors/ingressspec_test.go new file mode 100644 index 00000000..62e0789c --- /dev/null +++ b/pkg/mutation/editors/ingressspec_test.go @@ -0,0 +1,189 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" +) + +func TestIngressSpecEditor(t *testing.T) { + t.Run("Raw", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) + + t.Run("SetIngressClassName", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + editor.SetIngressClassName("nginx") + require.NotNil(t, spec.IngressClassName) + assert.Equal(t, "nginx", *spec.IngressClassName) + }) + + t.Run("SetDefaultBackend", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + backend := &networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + } + editor.SetDefaultBackend(backend) + assert.Equal(t, backend, spec.DefaultBackend) + }) + + t.Run("SetDefaultBackend nil", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + DefaultBackend: &networkingv1.IngressBackend{}, + } + editor := NewIngressSpecEditor(spec) + editor.SetDefaultBackend(nil) + assert.Nil(t, spec.DefaultBackend) + }) +} + +func TestIngressSpecEditor_EnsureRule(t *testing.T) { + t.Run("appends new rule", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + rule := networkingv1.IngressRule{Host: "example.com"} + editor.EnsureRule(rule) + require.Len(t, spec.Rules, 1) + assert.Equal(t, "example.com", spec.Rules[0].Host) + }) + + t.Run("upserts existing rule by host", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + {Host: "other.com"}, + }, + } + editor := NewIngressSpecEditor(spec) + updated := networkingv1.IngressRule{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + {Path: "/new"}, + }, + }, + }, + } + editor.EnsureRule(updated) + require.Len(t, spec.Rules, 2) + require.NotNil(t, spec.Rules[0].HTTP) + assert.Equal(t, "/new", spec.Rules[0].HTTP.Paths[0].Path) + assert.Equal(t, "other.com", spec.Rules[1].Host) + }) +} + +func TestIngressSpecEditor_RemoveRule(t *testing.T) { + t.Run("removes existing rule", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + {Host: "other.com"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveRule("example.com") + require.Len(t, spec.Rules, 1) + assert.Equal(t, "other.com", spec.Rules[0].Host) + }) + + t.Run("no-op for missing host", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveRule("nonexistent.com") + require.Len(t, spec.Rules, 1) + }) +} + +func TestIngressSpecEditor_EnsureTLS(t *testing.T) { + t.Run("appends new TLS entry", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + tls := networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-secret", + } + editor.EnsureTLS(tls) + require.Len(t, spec.TLS, 1) + assert.Equal(t, "tls-secret", spec.TLS[0].SecretName) + }) + + t.Run("upserts existing TLS by first host", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "old-secret"}, + {Hosts: []string{"other.com"}, SecretName: "other-secret"}, + }, + } + editor := NewIngressSpecEditor(spec) + tls := networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "new-secret", + } + editor.EnsureTLS(tls) + require.Len(t, spec.TLS, 2) + assert.Equal(t, "new-secret", spec.TLS[0].SecretName) + assert.Equal(t, "other-secret", spec.TLS[1].SecretName) + }) + + t.Run("ignores TLS with empty hosts", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + tls := networkingv1.IngressTLS{SecretName: "orphan"} + editor.EnsureTLS(tls) + assert.Empty(t, spec.TLS) + }) +} + +func TestIngressSpecEditor_RemoveTLS(t *testing.T) { + t.Run("removes matching TLS entries", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "s1"}, + {Hosts: []string{"other.com"}, SecretName: "s2"}, + {Hosts: []string{"third.com"}, SecretName: "s3"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveTLS("example.com", "third.com") + require.Len(t, spec.TLS, 1) + assert.Equal(t, "s2", spec.TLS[0].SecretName) + }) + + t.Run("no-op for missing hosts", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "s1"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveTLS("nonexistent.com") + require.Len(t, spec.TLS, 1) + }) + + t.Run("handles TLS entries with empty hosts", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "s1"}, + {SecretName: "no-hosts"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveTLS("example.com") + require.Len(t, spec.TLS, 1) + assert.Equal(t, "no-hosts", spec.TLS[0].SecretName) + }) +} diff --git a/pkg/primitives/ingress/builder.go b/pkg/primitives/ingress/builder.go new file mode 100644 index 00000000..0f926121 --- /dev/null +++ b/pkg/primitives/ingress/builder.go @@ -0,0 +1,141 @@ +package ingress + +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" + networkingv1 "k8s.io/api/networking/v1" +) + +// Builder is a configuration helper for creating and customizing an Ingress Resource. +// +// It provides a fluent API for registering mutations, status handlers, and data +// extractors. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*networkingv1.Ingress, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Ingress object. +// +// The Ingress 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 Ingress must have both Name and Namespace set, which is validated +// during the Build() call. +func NewBuilder(ing *networkingv1.Ingress) *Builder { + identityFunc := func(i *networkingv1.Ingress) string { + return fmt.Sprintf("networking.k8s.io/v1/Ingress/%s/%s", i.Namespace, i.Name) + } + + base := generic.NewIntegrationBuilder[*networkingv1.Ingress, *Mutator]( + ing, + identityFunc, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a mutation for the Ingress. +// +// 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 +} + +// WithCustomOperationalStatus overrides the default logic for determining if the +// Ingress has reached its operational state. +// +// The default behavior uses DefaultOperationalStatusHandler, which considers an +// Ingress operational when at least one IP or hostname is assigned in +// Status.LoadBalancer.Ingress. Use this method if your Ingress requires more +// complex health checks. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *networkingv1.Ingress) (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 immediately +// reports Suspended since the default suspension is a no-op. +func (b *Builder) WithCustomSuspendStatus( + handler func(*networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Ingress should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op. +// Deleting an Ingress causes ingress controller churn; the recommended approach +// is to let the backend service return 502/503. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the Ingress when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns false. +// Deleting an Ingress causes the ingress controller to reload its configuration, +// affecting the entire cluster's routing. Return true from this handler only if +// explicit deletion is required for your use case. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*networkingv1.Ingress) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to read values from the Ingress after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled Ingress. This is useful +// for surfacing generated or updated entries (such as assigned load balancer +// addresses) to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(networkingv1.Ingress) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(ing *networkingv1.Ingress) error { + return extractor(*ing) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Ingress object was provided. +// - The Ingress 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/ingress/builder_test.go b/pkg/primitives/ingress/builder_test.go new file mode 100644 index 00000000..04afaaef --- /dev/null +++ b/pkg/primitives/ingress/builder_test.go @@ -0,0 +1,213 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/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 + ingress *networkingv1.Ingress + expectedErr string + }{ + { + name: "nil ingress", + ingress: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid ingress", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.ingress).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, "networking.k8s.io/v1/Ingress/test-ns/test-ing", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(ing). + 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() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *networkingv1.Ingress) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing}, nil + } + res, err := NewBuilder(ing). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationNone, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusFailing, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ *networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(ing). + 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() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(ing). + 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() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ *networkingv1.Ingress) bool { + return true + } + res, err := NewBuilder(ing). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.True(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ networkingv1.Ingress) error { + called = true + return nil + } + res, err := NewBuilder(ing). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&networkingv1.Ingress{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(ing). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/ingress/handlers.go b/pkg/primitives/ingress/handlers.go new file mode 100644 index 00000000..433df000 --- /dev/null +++ b/pkg/primitives/ingress/handlers.go @@ -0,0 +1,66 @@ +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + networkingv1 "k8s.io/api/networking/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining if an Ingress +// has reached its operational state. +// +// It considers an Ingress operational when at least one IP or hostname is assigned +// in Status.LoadBalancer.Ingress. Until then it reports Pending. +// +// This function is used as the default handler by the Resource if no custom handler +// is registered via Builder.WithCustomOperationalStatus. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, ing *networkingv1.Ingress, +) (concepts.OperationalStatusWithReason, error) { + for _, lb := range ing.Status.LoadBalancer.Ingress { + if lb.IP != "" || lb.Hostname != "" { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Ingress has been assigned an address", + }, nil + } + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Awaiting load balancer address assignment", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete +// the Ingress when the parent component is suspended. +// +// It always returns false. Deleting an Ingress causes the ingress controller (e.g. +// nginx) to reload its configuration, which affects the entire cluster's routing — +// not just the suspended service. When a backend service is suspended, the Ingress +// returning 502/503 is the correct observable behaviour. +// +// Operators that want explicit deletion can set DeleteOnSuspend: true via +// Builder.WithCustomSuspendDeletionDecision. +func DefaultDeleteOnSuspendHandler(_ *networkingv1.Ingress) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to an Ingress +// when the component is suspended. +// +// It is a no-op. The Ingress is left in place and the backend service returning +// 502/503 is the expected observable behaviour during suspension. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// Since the default suspension is a no-op (the Ingress is not deleted or modified), +// it immediately reports Suspended with a reason indicating the backend is unavailable. +func DefaultSuspensionStatusHandler(_ *networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Ingress suspended (backend unavailable)", + }, nil +} diff --git a/pkg/primitives/ingress/handlers_test.go b/pkg/primitives/ingress/handlers_test.go new file mode 100644 index 00000000..3430d524 --- /dev/null +++ b/pkg/primitives/ingress/handlers_test.go @@ -0,0 +1,212 @@ +package ingress + +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" + networkingv1 "k8s.io/api/networking/v1" +) + +func TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + ingress *networkingv1.Ingress + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "pending with no load balancer ingress", + op: concepts.ConvergingOperationCreated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{}, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer address assignment", + }, + { + name: "pending with empty load balancer ingress slice", + op: concepts.ConvergingOperationUpdated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{}, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer address assignment", + }, + { + name: "pending with non-empty slice but empty entries", + op: concepts.ConvergingOperationCreated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer address assignment", + }, + { + name: "operational with IP assigned", + op: concepts.ConvergingOperationCreated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Ingress has been assigned an address", + }, + { + name: "operational with hostname assigned", + op: concepts.ConvergingOperationNone, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {Hostname: "lb.example.com"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Ingress has been assigned an address", + }, + { + name: "operational with multiple entries", + op: concepts.ConvergingOperationUpdated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + {Hostname: "lb.example.com"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Ingress has been assigned an address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(tt.op, tt.ingress) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + ing := &networkingv1.Ingress{} + assert.False(t, DefaultDeleteOnSuspendHandler(ing)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + ing := &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + }, + }, + } + mutator := NewMutator(ing) + mutator.BeginFeature() + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + // No-op: rules should remain unchanged. + assert.Len(t, ing.Spec.Rules, 1) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + t.Run("always reports suspended", func(t *testing.T) { + ing := &networkingv1.Ingress{} + got, err := DefaultSuspensionStatusHandler(ing) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "Ingress suspended (backend unavailable)", got.Reason) + }) + + t.Run("suspended even with load balancer assigned", func(t *testing.T) { + ing := &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + }, + } + got, err := DefaultSuspensionStatusHandler(ing) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + }) +} + +// Ensure the handler ignores the ConvergingOperation parameter (tested with all values). +func TestDefaultOperationalStatusHandler_IgnoresOperation(t *testing.T) { + ing := &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + }, + } + + ops := []concepts.ConvergingOperation{ + concepts.ConvergingOperationCreated, + concepts.ConvergingOperationUpdated, + concepts.ConvergingOperationNone, + } + + for _, op := range ops { + got, err := DefaultOperationalStatusHandler(op, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, got.Status) + } +} + +// Verify the handler works with the core/v1 LoadBalancerIngress type embedded +// in the networking/v1 IngressLoadBalancerIngress. The Ports field is a +// networking/v1 extension. +func TestDefaultOperationalStatusHandler_WithPorts(t *testing.T) { + ing := &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + { + IP: "10.0.0.1", + Ports: []networkingv1.IngressPortStatus{ + { + Port: 80, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + }, + }, + }, + } + + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationCreated, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, got.Status) +} diff --git a/pkg/primitives/ingress/mutator.go b/pkg/primitives/ingress/mutator.go new file mode 100644 index 00000000..62ff52ed --- /dev/null +++ b/pkg/primitives/ingress/mutator.go @@ -0,0 +1,122 @@ +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + networkingv1 "k8s.io/api/networking/v1" +) + +// Mutation defines a mutation that is applied to an ingress Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + ingressSpecEdits []func(*editors.IngressSpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes Ingress. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Ingress 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. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Ingress spec edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + ing *networkingv1.Ingress + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Ingress. +// BeginFeature should be called before registering mutations to establish +// feature boundaries. If omitted, EditObjectMetadata and EditIngressSpec +// will call it implicitly. +func NewMutator(ing *networkingv1.Ingress) *Mutator { + return &Mutator{ + ing: ing, + } +} + +// 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 Ingress's own metadata. +// +// Metadata edits are applied before ingress spec edits within the same feature. +// A nil edit function is ignored. +// If BeginFeature has not been called, it is called implicitly. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + if m.active == nil { + m.BeginFeature() + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditIngressSpec records a mutation for the Ingress's spec via an IngressSpecEditor. +// +// The editor provides structured operations (SetIngressClassName, SetDefaultBackend, +// EnsureRule, RemoveRule, EnsureTLS, RemoveTLS) as well as Raw() for free-form access. +// Ingress spec edits are applied after metadata edits within the same feature, in +// registration order. +// +// A nil edit function is ignored. +// If BeginFeature has not been called, it is called implicitly. +func (m *Mutator) EditIngressSpec(edit func(*editors.IngressSpecEditor) error) { + if edit == nil { + return + } + if m.active == nil { + m.BeginFeature() + } + m.active.ingressSpecEdits = append(m.active.ingressSpecEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying Ingress. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Ingress spec edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Ingress 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.ing.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Ingress spec edits + if len(plan.ingressSpecEdits) > 0 { + editor := editors.NewIngressSpecEditor(&m.ing.Spec) + for _, edit := range plan.ingressSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/ingress/mutator_test.go b/pkg/primitives/ingress/mutator_test.go new file mode 100644 index 00000000..c3faeb97 --- /dev/null +++ b/pkg/primitives/ingress/mutator_test.go @@ -0,0 +1,284 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestIngress() *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "default", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", ing.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditObjectMetadata_Error(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + 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") +} + +// --- EditIngressSpec --- + +func TestMutator_EditIngressSpec(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + require.NoError(t, m.Apply()) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) +} + +func TestMutator_EditIngressSpec_Nil(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditIngressSpec_Error(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(_ *editors.IngressSpecEditor) error { + return errors.New("spec error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "spec error") +} + +func TestMutator_EditIngressSpec_EnsureRule(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, ing.Spec.Rules, 1) + assert.Equal(t, "example.com", ing.Spec.Rules[0].Host) +} + +func TestMutator_EditIngressSpec_EnsureTLS(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-secret", + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, ing.Spec.TLS, 1) + assert.Equal(t, "tls-secret", ing.Spec.TLS[0].SecretName) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before spec edits. + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Equal(t, "tested", ing.Labels["order"]) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "feature1.com"}) + return nil + }) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "feature2.com"}) + return nil + }) + require.NoError(t, m.Apply()) + + require.Len(t, ing.Spec.Rules, 2) + assert.Equal(t, "feature1.com", ing.Spec.Rules[0].Host) + assert.Equal(t, "feature2.com", ing.Spec.Rules[1].Host) +} + +func TestMutator_CombinedMutations(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "web") + e.EnsureAnnotation("description", "main ingress") + return nil + }) + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + return nil + }) + + require.NoError(t, m.Apply()) + + assert.Equal(t, "web", ing.Labels["app"]) + assert.Equal(t, "main ingress", ing.Annotations["description"]) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) + require.Len(t, ing.Spec.Rules, 1) + require.Len(t, ing.Spec.TLS, 1) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + + 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) { + ing := newTestIngress() + m := NewMutator(ing) + + 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) { + ing := newTestIngress() + m := NewMutator(ing) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "f0.com"}) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "f1.com"}) + return nil + }) + + assert.Len(t, m.plans[0].ingressSpecEdits, 1, "first plan should have one spec edit") + assert.Len(t, m.plans[1].ingressSpecEdits, 1, "second plan should have one spec edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + + require.NoError(t, m.Apply()) + require.Len(t, m.plans, 1, "single feature should have exactly one plan") +} + +// --- Implicit BeginFeature --- + +func TestMutator_EditObjectMetadata_ImplicitBeginFeature(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + // No explicit BeginFeature call — should not panic. + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("implicit", "true") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "true", ing.Labels["implicit"]) +} + +func TestMutator_EditIngressSpec_ImplicitBeginFeature(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + // No explicit BeginFeature call — should not panic. + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + require.NoError(t, m.Apply()) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go new file mode 100644 index 00000000..715d8a0e --- /dev/null +++ b/pkg/primitives/ingress/resource.go @@ -0,0 +1,96 @@ +// Package ingress provides a builder and resource for managing Kubernetes Ingresses. +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes Ingress within +// a controller's reconciliation loop. +// +// It implements the following component lifecycle interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Operational: for tracking whether the ingress has been assigned an address. +// - concepts.Suspendable: for controlled suspension when the parent component is suspended. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// +// Ingress resources are integration primitives: they depend on an external ingress +// controller to assign load balancer addresses. The default operational status handler +// reports OperationPending (concepts.OperationalStatusPending) until at least one IP or +// hostname is assigned, then Operational. +type Resource struct { + base *generic.IntegrationResource[*networkingv1.Ingress, *Mutator] +} + +// Identity returns a unique identifier for the Ingress in the format +// "networking.k8s.io/v1/Ingress//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes Ingress 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 Ingress into the desired state. +// +// The mutation process follows this order: +// 1. Feature mutations: all registered feature-gated mutations are applied in order. +// 2. Suspension mutation: if the component is suspended, the suspension mutation is applied. +// +// This method is invoked by the framework during the reconciliation loop. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates whether the Ingress has reached its operational state. +// +// By default, it uses DefaultOperationalStatusHandler, which reports OperationPending +// until the ingress controller has assigned at least one IP or hostname, then Operational. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the Ingress should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false. Deleting +// an Ingress causes the ingress controller to reload its configuration, affecting +// the entire cluster's routing — not just the suspended service. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the suspension of the Ingress. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior is a no-op — the Ingress is left in place and the +// backend service returning 502/503 is the expected observable behaviour. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which immediately reports +// Suspended with a reason indicating the backend is unavailable. +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 Ingress. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values (such as assigned load balancer +// addresses) from the Ingress. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go new file mode 100644 index 00000000..3233780a --- /dev/null +++ b/pkg/primitives/ingress/resource_test.go @@ -0,0 +1,245 @@ +package ingress + +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" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func newValidIngress() *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "test-ns", + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + assert.Equal(t, "networking.k8s.io/v1/Ingress/test-ns/test-ingress", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + ing := newValidIngress() + res, err := NewBuilder(ing).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*networkingv1.Ingress) + require.True(t, ok) + assert.Equal(t, ing.Name, got.Name) + assert.Equal(t, ing.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-ingress", ing.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidIngress() + 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.(*networkingv1.Ingress) + assert.Equal(t, ptr.To("nginx"), got.Spec.IngressClassName) + require.Len(t, got.Spec.Rules, 1) + assert.Equal(t, "example.com", got.Spec.Rules[0].Host) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidIngress() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-tls", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + 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.(*networkingv1.Ingress) + assert.Equal(t, ptr.To("nginx"), got.Spec.IngressClassName) + require.Len(t, got.Spec.TLS, 1) + assert.Equal(t, "tls-cert", got.Spec.TLS[0].SecretName) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidIngress() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("traefik") + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("haproxy") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + // Last mutation wins. + got := obj.(*networkingv1.Ingress) + assert.Equal(t, ptr.To("haproxy"), got.Spec.IngressClassName) +} + +func TestDefaultOperationalStatusHandler_Operational(t *testing.T) { + ing := newValidIngress() + ing.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + } + + status, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationUpdated, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) +} + +func TestDefaultOperationalStatusHandler_OperationalWithHostname(t *testing.T) { + ing := newValidIngress() + ing.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {Hostname: "lb.example.com"}, + }, + }, + } + + status, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationUpdated, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) +} + +func TestResource_ConvergingStatus_Pending(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationCreated) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + assert.False(t, res.DeleteOnSuspend()) +} + +func TestResource_Suspend(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + require.NoError(t, res.Suspend()) +} + +func TestResource_SuspensionStatus(t *testing.T) { + res, err := NewBuilder(newValidIngress()).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) { + ing := newValidIngress() + + var extracted string + res, err := NewBuilder(ing). + WithDataExtractor(func(i networkingv1.Ingress) error { + extracted = *i.Spec.IngressClassName + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "nginx", extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidIngress()). + WithDataExtractor(func(_ networkingv1.Ingress) 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") +}