-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement service primitive #32
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
Changes from all commits
798d599
e142f7b
363ba66
d7390a6
7d716e4
2bad8c3
ee9c24b
80e8315
1324008
1954ebc
66564fb
bf28a1b
b7057e3
6799d6f
d37ee58
65ac291
a77a53b
a43bdc3
9a1b9d2
097408e
8c7c90b
841eb73
f4d3db1
ac004a6
4ea2ab0
e46b4bf
340788f
f1f6721
b231013
b804356
7b3620a
82714ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||
| 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. | ||
| 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
|
||||||
| - **Field Flavors**: Preserving annotations that might be managed by external tools (e.g., cloud load balancer | |
| controllers). |
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.
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.