Skip to content
Merged
Show file tree
Hide file tree
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 Mar 22, 2026
e8b1260
Add Ingress primitive (Integration lifecycle)
sourcehawk Mar 22, 2026
6e2be93
Add documentation for the Ingress primitive
sourcehawk Mar 22, 2026
e357caf
Add Ingress primitive example
sourcehawk Mar 22, 2026
9f796d7
Add package comment to features package for lint compliance
sourcehawk Mar 22, 2026
b71f887
Fix operational status handler to validate LB entry fields
sourcehawk Mar 22, 2026
6cfb139
Address review feedback for Ingress primitive
sourcehawk Mar 22, 2026
38997d8
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
788752a
Address review feedback for Ingress primitive
sourcehawk Mar 22, 2026
5b2badb
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
edb8e4f
Preserve live Status in DefaultFieldApplicator and add test coverage
sourcehawk Mar 23, 2026
d829384
Use generic PreserveStatus in ingress DefaultFieldApplicator
sourcehawk Mar 23, 2026
f68ec34
Remove beginFeature() call from ingress Mutator constructor
sourcehawk Mar 23, 2026
3964dcf
Export BeginFeature() to satisfy updated FeatureMutator interface
sourcehawk Mar 23, 2026
0597a79
Merge main into feature/ingress-primitive and resolve conflict
sourcehawk Mar 24, 2026
373c591
Format markdown files with prettier
sourcehawk Mar 24, 2026
5e6242c
Assert Build() errors in flavors_test.go to prevent silent failures
sourcehawk Mar 24, 2026
fe55cdf
Update operational status terminology in Resource doc comment
sourcehawk Mar 24, 2026
fc6bfff
Merge remote-tracking branch 'origin/main' into feature/ingress-primi…
sourcehawk Mar 24, 2026
32e1fce
Do not initialize an empty plan on ingress mutator construction
sourcehawk Mar 24, 2026
1464bb2
Merge remote-tracking branch 'origin/main' into feature/ingress-primi…
sourcehawk Mar 25, 2026
56c03ca
Remove field applicators and flavors from ingress primitive
sourcehawk Mar 25, 2026
d0fc292
Remove references to deleted field applicators in ingress example
sourcehawk Mar 25, 2026
74df3e9
Remove stale Field Flavors reference from ingress example README
sourcehawk Mar 25, 2026
ff1c2a9
Implicitly call BeginFeature in EditObjectMetadata and EditIngressSpec
sourcehawk Mar 25, 2026
22c6904
Merge branch 'main' into feature/ingress-primitive
sourcehawk Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
270 changes: 270 additions & 0 deletions docs/primitives/ingress.md
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` |
Copy link

Copilot AI Mar 25, 2026

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 returns concepts.OperationalStatusPending and 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 explicitly OperationalStatusPending/OperationalStatusOperational).

Suggested change
| **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` |
| **Operational status** | Reports `Pending` until the ingress controller assigns an address, then `Operational` |

Copilot uses AI. Check for mistakes.
| **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 |
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs refer to OperationPending, but the implementation (and other docs/tests) use concepts.OperationalStatusPending / 'Pending' terminology. Please align the documentation to the actual status enum names to avoid confusion for API consumers.

Copilot uses AI. Check for mistakes.

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.
32 changes: 32 additions & 0 deletions examples/ingress-primitive/README.md
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.
54 changes: 54 additions & 0 deletions examples/ingress-primitive/app/controller.go
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)
}
20 changes: 20 additions & 0 deletions examples/ingress-primitive/app/owner.go
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
Loading
Loading