-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement ingress primitive #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+2,278
−0
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
e97fa34
Add IngressSpecEditor for typed Ingress spec mutations
sourcehawk e8b1260
Add Ingress primitive (Integration lifecycle)
sourcehawk 6e2be93
Add documentation for the Ingress primitive
sourcehawk e357caf
Add Ingress primitive example
sourcehawk 9f796d7
Add package comment to features package for lint compliance
sourcehawk b71f887
Fix operational status handler to validate LB entry fields
sourcehawk 6cfb139
Address review feedback for Ingress primitive
sourcehawk 38997d8
preserve server-managed metadata in default field applicator
sourcehawk 788752a
Address review feedback for Ingress primitive
sourcehawk 5b2badb
preserve server-managed metadata in default field applicator
sourcehawk edb8e4f
Preserve live Status in DefaultFieldApplicator and add test coverage
sourcehawk d829384
Use generic PreserveStatus in ingress DefaultFieldApplicator
sourcehawk f68ec34
Remove beginFeature() call from ingress Mutator constructor
sourcehawk 3964dcf
Export BeginFeature() to satisfy updated FeatureMutator interface
sourcehawk 0597a79
Merge main into feature/ingress-primitive and resolve conflict
sourcehawk 373c591
Format markdown files with prettier
sourcehawk 5e6242c
Assert Build() errors in flavors_test.go to prevent silent failures
sourcehawk fe55cdf
Update operational status terminology in Resource doc comment
sourcehawk fc6bfff
Merge remote-tracking branch 'origin/main' into feature/ingress-primi…
sourcehawk 32e1fce
Do not initialize an empty plan on ingress mutator construction
sourcehawk 1464bb2
Merge remote-tracking branch 'origin/main' into feature/ingress-primi…
sourcehawk 56c03ca
Remove field applicators and flavors from ingress primitive
sourcehawk d0fc292
Remove references to deleted field applicators in ingress example
sourcehawk 74df3e9
Remove stale Field Flavors reference from ingress example README
sourcehawk ff1c2a9
Implicitly call BeginFeature in EditObjectMetadata and EditIngressSpec
sourcehawk 22c6904
Merge branch 'main' into feature/ingress-primitive
sourcehawk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs reference
OperationPending, but the code returnsconcepts.OperationalStatusPendingand the status concept elsewhere is “Pending” vs “Operational”. To avoid confusing API users, align terminology with the actual status names used by the framework (e.g., “Pending”/“Operational”, or explicitlyOperationalStatusPending/OperationalStatusOperational).