Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
798d599
Add ServiceSpecEditor for mutating Kubernetes ServiceSpec
sourcehawk Mar 22, 2026
e142f7b
Add Service primitive (Integration lifecycle)
sourcehawk Mar 22, 2026
363ba66
Add Service primitive documentation
sourcehawk Mar 22, 2026
d7390a6
Add Service primitive example
sourcehawk Mar 22, 2026
7d716e4
Fix lint issues in ServiceSpecEditor
sourcehawk Mar 22, 2026
2bad8c3
Address PR review: fix LoadBalancer status check, add resource tests,…
sourcehawk Mar 22, 2026
ee9c24b
add files
sourcehawk Mar 22, 2026
80e8315
make service not deleted by default
sourcehawk Mar 22, 2026
1324008
Address Copilot review: preserve Status in field applicator, fix doc …
sourcehawk Mar 23, 2026
1954ebc
Use generic PreserveStatus in service field applicator
sourcehawk Mar 23, 2026
66564fb
Fix service mutator constructor to not call beginFeature
sourcehawk Mar 23, 2026
bf28a1b
Address PR review: export BeginFeature, preserve nodePorts, add tests
sourcehawk Mar 23, 2026
b7057e3
fix linter error
sourcehawk Mar 23, 2026
6799d6f
Merge remote-tracking branch 'origin/main' into feature/service-primi…
sourcehawk Mar 24, 2026
d37ee58
Format markdown files with prettier
sourcehawk Mar 24, 2026
65ac291
Address Copilot review feedback for service primitive
sourcehawk Mar 24, 2026
a77a53b
Fix markdown table formatting in primitives.md
sourcehawk Mar 24, 2026
a43bdc3
Guard NodePort preservation by desired Service type
sourcehawk Mar 24, 2026
9a1b9d2
Document NodePort preservation in DefaultFieldApplicator
sourcehawk Mar 24, 2026
097408e
Fix Prettier formatting in service.md
sourcehawk Mar 24, 2026
8c7c90b
Restore auto-allocated NodePorts after mutation edits
sourcehawk Mar 24, 2026
841eb73
Preserve HealthCheckNodePort and IPFamilies in DefaultFieldApplicator
sourcehawk Mar 24, 2026
f4d3db1
Fix Prettier formatting in service.md
sourcehawk Mar 24, 2026
ac004a6
Merge remote-tracking branch 'origin/main' into feature/service-primi…
sourcehawk Mar 24, 2026
4ea2ab0
Fix service Mutator to not initialize a feature plan on construction
sourcehawk Mar 24, 2026
e46b4bf
Fix field name in service.md example to match ExampleApp spec
sourcehawk Mar 24, 2026
340788f
format
sourcehawk Mar 24, 2026
f1f6721
Gate healthCheckNodePort preservation on LB+Local and restrict unname…
sourcehawk Mar 24, 2026
b231013
Add service-primitive example to run-examples Makefile target
sourcehawk Mar 24, 2026
b804356
Merge remote-tracking branch 'origin/main' into feature/service-primi…
sourcehawk Mar 25, 2026
7b3620a
Remove field applicators and flavors from service primitive for SSA m…
sourcehawk Mar 25, 2026
82714ca
Merge branch 'main' into feature/service-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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ run-examples: ## Run all examples to verify they execute without error.
go run ./examples/replicaset-primitive/.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.
go run ./examples/service-primitive/.
Comment on lines 127 to +130
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.

PR description checklist says "Does not modify shared files", but this change updates the root Makefile (and also adds shared framework code under pkg/mutation/editors and docs). Please update the PR description/checklist to reflect that shared files are modified, or move these changes to a separate PR if that’s the project expectation.

Copilot uses AI. Check for mistakes.
go run ./examples/role-primitive/.
go run ./examples/pdb-primitive/.
go run ./examples/daemonset-primitive/.
Expand Down
1 change: 1 addition & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ have been applied. This means a single mutation can safely add a container and t
| `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) |
| `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) |
| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) |
| `pkg/primitives/service` | Integration | [service.md](primitives/service.md) |
| `pkg/primitives/pv` | Integration | [pv.md](primitives/pv.md) |
| `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) |
| `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) |
Expand Down
316 changes: 316 additions & 0 deletions docs/primitives/service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
# Service Primitive

The `service` primitive is the framework's built-in integration abstraction for managing Kubernetes `Service` resources.
It integrates with the component lifecycle and provides a structured mutation API for managing ports, selectors, and
service configuration.

## Capabilities

| Capability | Detail |
| ------------------------ | --------------------------------------------------------------------------------------------- |
| **Operational tracking** | Monitors LoadBalancer ingress assignment; reports `Operational` or `Pending` |
| **Suspension** | Unaffected by suspension by default; customizable via handlers to delete or mutate on suspend |
| **Mutation pipeline** | Typed editors for metadata and service spec, with a raw escape hatch for free-form access |
| **Data extraction** | Reads generated or updated values (ClusterIP, LoadBalancer ingress) after each sync cycle |

## Building a Service Primitive

```go
import "github.com/sourcehawk/operator-component-framework/pkg/primitives/service"

base := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "app-service",
Namespace: owner.Namespace,
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": owner.Name},
Ports: []corev1.ServicePort{
{Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080)},
},
},
}

resource, err := service.NewBuilder(base).
WithMutation(MyFeatureMutation(owner.Spec.Version)).
Build()
```

## Mutations

Mutations are the primary mechanism for modifying a `Service` beyond its baseline. Each mutation is a named function
that receives a `*Mutator` and records edit intent through typed editors.

The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
with no version constraints and no `When()` conditions is also always enabled:

```go
func MyFeatureMutation(version string) service.Mutation {
return service.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *service.Mutator) error {
// record edits here
return nil
},
}
}
```

Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
another, register the dependency first.

### Boolean-gated mutations

Use `When(bool)` to gate a mutation on a runtime condition:

```go
func NodePortMutation(version string, enabled bool) service.Mutation {
return service.Mutation{
Name: "nodeport",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *service.Mutator) error {
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.SetType(corev1.ServiceTypeNodePort)
return nil
})
return nil
},
}
}
```

### Version-gated mutations

Pass a `[]feature.VersionConstraint` to gate on a semver range:

```go
var legacyConstraint = mustSemverConstraint("< 2.0.0")

func LegacyPortMutation(version string) service.Mutation {
return service.Mutation{
Name: "legacy-port",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *service.Mutator) error {
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsurePort(corev1.ServicePort{Name: "legacy", Port: 9090})
return nil
})
return nil
},
}
}
```

All version constraints and `When()` conditions must be satisfied for a mutation to apply.

## Internal Mutation Ordering

Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
order they are recorded:

| Step | Category | What it affects |
| ---- | ----------------- | ---------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `Service` |
| 2 | ServiceSpec edits | Ports, selectors, type, traffic policies |

Comment on lines +115 to +119
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 "Internal Mutation Ordering" table also starts each row with ||, which adds an unintended empty column in rendered markdown. Please fix the table syntax to use | ... | consistently.

Copilot uses AI. Check for mistakes.
Within each category, edits are applied in their registration order. Later features observe the Service as modified by
all previous features.

## Editors

### ServiceSpecEditor

Controls service-level settings via `m.EditServiceSpec`.

Available methods: `SetType`, `EnsurePort`, `RemovePort`, `SetSelector`, `EnsureSelector`, `RemoveSelector`,
`SetSessionAffinity`, `SetSessionAffinityConfig`, `SetPublishNotReadyAddresses`, `SetExternalTrafficPolicy`,
`SetInternalTrafficPolicy`, `SetLoadBalancerSourceRanges`, `SetExternalName`, `Raw`.

```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.SetType(corev1.ServiceTypeLoadBalancer)
e.EnsurePort(corev1.ServicePort{
Name: "https",
Port: 443,
TargetPort: intstr.FromInt32(8443),
})
e.SetExternalTrafficPolicy(corev1.ServiceExternalTrafficPolicyLocal)
return nil
})
```

#### Port Management

`EnsurePort` upserts a port: if a port with the same `Name` exists, it is replaced; otherwise, when `Name` is empty, the
match is performed on the combination of `Port` and the effective `Protocol` (treating an empty protocol value as TCP).
This means TCP and UDP ports with the same port number are considered distinct unless you explicitly set matching
protocols. If no existing port matches, the new port is appended. `RemovePort` removes a port by name.

```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80})
e.RemovePort("legacy")
return nil
})
```

#### Selector Management

`SetSelector` replaces the entire selector map. `EnsureSelector` adds or updates a single key-value pair.
`RemoveSelector` removes a single key.

```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsureSelector("app", "myapp")
e.EnsureSelector("env", "production")
return nil
})
```

For fields not covered by the typed API, use `Raw()`:

```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.Raw().HealthCheckNodePort = 30000
return nil
})
```

### ObjectMetaEditor

Modifies labels and annotations via `m.EditObjectMetadata`.

Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.

```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
e.EnsureAnnotation("service.beta.kubernetes.io/aws-load-balancer-type", "nlb")
return nil
})
```

## Operational Status

The Service primitive implements the `Operational` concept to track whether the Service is ready to accept traffic.

### DefaultOperationalStatusHandler

| Service Type | Behaviour |
| -------------- | ------------------------------------------------------------------------------------------------------------ |
| `LoadBalancer` | Reports `Pending` until `Status.LoadBalancer.Ingress` has entries with an IP or hostname; then `Operational` |
| `ClusterIP` | Immediately `Operational` |
| `NodePort` | Immediately `Operational` |
| `ExternalName` | Immediately `Operational` |
| Headless | Immediately `Operational` |

Override with `WithCustomOperationalStatus` to add custom checks:

```go
resource, err := service.NewBuilder(base).
WithCustomOperationalStatus(func(op concepts.ConvergingOperation, svc *corev1.Service) (concepts.OperationalStatusWithReason, error) {
// Custom logic, e.g. check for specific annotations
return service.DefaultOperationalStatusHandler(op, svc)
}).
Build()
```

## Suspension

By default, Services are **unaffected** by suspension — they remain in the cluster when the parent component is
suspended. The default suspend mutation handler is a no-op, `DefaultDeleteOnSuspendHandler` returns `false`, and the
default suspension status handler reports `Suspended` immediately (no work required).

This is appropriate for most use cases because Services are stateless routing objects that are safe to leave in place.

Override with `WithCustomSuspendDeletionDecision` if you want to delete the Service on suspend:

```go
resource, err := service.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(_ *corev1.Service) bool {
return true // delete the Service during suspension
}).
Build()
```

You can also combine `WithCustomSuspendMutation` and `WithCustomSuspendStatus` for more advanced suspension behaviour,
such as modifying the Service before it is deleted or tracking external readiness before reporting suspended.

## Data Extraction

Use `WithDataExtractor` to read values from the reconciled Service, such as the assigned ClusterIP or LoadBalancer
ingress:

```go
var assignedIP string

resource, err := service.NewBuilder(base).
WithDataExtractor(func(svc corev1.Service) error {
assignedIP = svc.Spec.ClusterIP
return nil
}).
Build()
```

## Full Example: Feature-Composed Service

```go
func BaseServiceMutation(version string) service.Mutation {
return service.Mutation{
Name: "base-service",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *service.Mutator) error {
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsurePort(corev1.ServicePort{
Name: "http",
Port: 80,
TargetPort: intstr.FromInt32(8080),
})
return nil
})
return nil
},
}
}

func MetricsPortMutation(version string, enabled bool) service.Mutation {
return service.Mutation{
Name: "metrics-port",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *service.Mutator) error {
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsurePort(corev1.ServicePort{
Name: "metrics",
Port: 9090,
TargetPort: intstr.FromInt32(9090),
})
return nil
})
return nil
},
}
}

resource, err := service.NewBuilder(base).
WithMutation(BaseServiceMutation(owner.Spec.Version)).
WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)).
Build()
```

When `EnableMetrics` is true, the Service will expose both the HTTP port and the metrics port. When false, only the HTTP
port is configured. Neither mutation needs to know about the other.

## Guidance

**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for
boolean conditions.

**Register mutations in dependency order.** If mutation B relies on a port added by mutation A, register A first.

**Use `EnsurePort` for idempotent port management.** The mutator tracks ports by name (or port number when unnamed), so
repeated calls with the same name produce the same result.
35 changes: 35 additions & 0 deletions examples/service-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Service Primitive Example

This example demonstrates the usage of the `service` primitive within the operator component framework. It shows how to
manage a Kubernetes Service as a component of a larger application, utilising features like:

- **Base Construction**: Initializing a Service with basic metadata, selector, and ports.
- **Feature Mutations**: Applying version-gated or conditional changes (additional ports, labels) using the `Mutator`.
- **Field Flavors**: Preserving annotations that might be managed by external tools (e.g., cloud load balancer
controllers).
Comment on lines +8 to +9
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.

This README claims the example demonstrates "Field Flavors" (preserving externally managed annotations), but the service primitive builder/mutator exposed in this PR doesn’t include any flavor API or implementation. Either remove this bullet or add the missing flavor support so the documentation matches the code.

Suggested change
- **Field Flavors**: Preserving annotations that might be managed by external tools (e.g., cloud load balancer
controllers).

Copilot uses AI. Check for mistakes.
- **Operational Status**: Tracking whether the Service is operational (relevant for LoadBalancer types).
- **Suspension**: Demonstrating that, by default, the Service remains present when the component is suspended
(`DeleteOnSuspend=false`), and how to opt into deletion if desired.
- **Data Extraction**: Harvesting information (ClusterIP, ports) from the reconciled resource.

## Directory Structure

- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from
`examples/shared/app`.
- `features/`: Contains modular feature definitions:
- `mutations.go`: version labelling and conditional metrics port.
- `resources/`: Contains the central `NewServiceResource` factory that assembles all features using `service.Builder`.
- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client.

## Running the Example

```bash
go run examples/service-primitive/main.go
```

This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile through four spec variations, printing the Service ports after each cycle.
4. Print the resulting status conditions.
Loading
Loading