Service Primitive¶
The service primitive wraps a Kubernetes Service and integrates with the component lifecycle as an Integration,
Graceful, and Suspendable resource.
Capabilities¶
| Capability | Detail |
|---|---|
| Operational | Monitors LoadBalancer ingress assignment; reports Operational or OperationPending |
| Graceful | LoadBalancer with no ingress reports Degraded; non-LoadBalancer or assigned ingress is Healthy |
| Suspendable | No-op by default; Service is left in place. Customizable via handlers |
| DataExtractable | Reads assigned ClusterIP or LoadBalancer ingress after each sync cycle |
| Mutation pipeline | Typed editors for metadata and Service spec, with a Raw() escape hatch for free-form access |
See Lifecycle Interfaces for the full set of status values each interface reports.
Building a Service Primitive¶
import "github.com/sourcehawk/operator-component-framework/pkg/primitives/service"
base := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "app-svc",
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(BaseServiceMutation(owner.Spec.Version)).
Build()
Mutations¶
Register mutations with WithMutation. The mutation system, boolean-gated mutations, and version-gated mutations are
explained in The Mutation System,
Boolean-Gated Mutations, and
Version-Gated Mutations.
A kind-specific example, gating a NodePort mutation on a boolean condition:
func NodePortMutation(version string, enabled bool) service.Mutation {
return service.Mutation{
Name: "nodeport",
Feature: feature.NewVersionGate(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
},
}
}
Internal Mutation Ordering¶
Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
|---|---|---|
| 1 | Metadata edits | Labels and annotations on the Service |
| 2 | ServiceSpec | Ports, selectors, type, traffic policies |
Within each category, edits run in registration order. Later features observe the Service as modified by all earlier ones.
Relevant Editors¶
See Mutation Editors for the general editor model.
ServiceSpecEditor¶
Controls Service-level settings via m.EditServiceSpec.
Available methods: SetType, EnsurePort, RemovePort, SetSelector, EnsureSelector, RemoveSelector,
SetSessionAffinity, SetSessionAffinityConfig, SetPublishNotReadyAddresses, SetExternalTrafficPolicy,
SetInternalTrafficPolicy, SetLoadBalancerSourceRanges, SetExternalName, Raw.
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; when Name is empty the match uses
the combination of Port and the effective Protocol (treating an empty protocol as TCP). TCP and UDP ports with the
same port number are distinct unless protocols match explicitly. If no existing port matches, the new port is appended.
RemovePort removes a port by name.
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.
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsureSelector("app", "web")
e.EnsureSelector("tier", "frontend")
return nil
})
Use Raw() for fields not covered by the typed API:
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.
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
})
Data Extraction¶
Use WithDataExtractor to read values from the reconciled Service after each sync cycle, such as the assigned ClusterIP
or LoadBalancer ingress:
var assignedIP string
resource, err := service.NewBuilder(base).
WithDataExtractor(func(svc corev1.Service) error {
assignedIP = svc.Spec.ClusterIP
return nil
}).
Build()
Operational Status¶
The Service primitive implements concepts.Operational. The default handler reports:
| Service Type | Condition | Status |
|---|---|---|
LoadBalancer |
Status.LoadBalancer.Ingress has no entry with an IP or hostname |
OperationPending |
LoadBalancer |
Status.LoadBalancer.Ingress has at least one entry with an IP or hostname |
Operational |
ClusterIP |
Always | Operational |
NodePort |
Always | Operational |
ExternalName |
Always | Operational |
| Headless | Always | Operational |
Override with WithCustomOperationalStatus:
resource, err := service.NewBuilder(base).
WithCustomOperationalStatus(func(op concepts.ConvergingOperation, svc *corev1.Service) (concepts.OperationalStatusWithReason, error) {
return service.DefaultOperationalStatusHandler(op, svc)
}).
Build()
Grace Status¶
The default grace status handler assesses health after the grace period expires:
| Service Type | Condition | Status |
|---|---|---|
LoadBalancer |
Status.LoadBalancer.Ingress has entries |
Healthy |
LoadBalancer |
Status.LoadBalancer.Ingress is empty |
Degraded |
ClusterIP |
Always | Healthy |
NodePort |
Always | Healthy |
ExternalName |
Always | Healthy |
| Headless | Always | Healthy |
Override with WithCustomGraceStatus:
service.NewBuilder(base).
WithCustomGraceStatus(func(svc *corev1.Service) (concepts.GraceStatusWithReason, error) {
status, err := service.DefaultGraceStatusHandler(svc)
if err != nil {
return status, err
}
// Add custom logic
return status, nil
})
Suspension¶
By default, Services are unaffected by suspension. They remain in the cluster when the parent component is suspended.
DefaultDeleteOnSuspendHandler returns false, DefaultSuspendMutationHandler is a no-op, and
DefaultSuspensionStatusHandler reports Suspended immediately.
This is appropriate for most use cases because Services are stateless routing objects that are safe to leave in place.
Override with WithCustomSuspendDeletionDecision to delete the Service on suspend:
resource, err := service.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(_ *corev1.Service) bool {
return true
}).
Build()
Combine WithCustomSuspendMutation and WithCustomSuspendStatus for more advanced suspension behavior, such as
modifying the Service before deletion or tracking external readiness before reporting suspended.
Full Example¶
func BaseServiceMutation(version string) service.Mutation {
return service.Mutation{
Name: "base-service",
Feature: feature.NewVersionGate(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.NewVersionGate(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
},
}
}
var assignedIP string
resource, err := service.NewBuilder(base).
WithMutation(BaseServiceMutation(owner.Spec.Version)).
WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)).
WithDataExtractor(func(svc corev1.Service) error {
assignedIP = svc.Spec.ClusterIP
return nil
}).
Build()
When EnableMetrics is true, the Service exposes both the HTTP and metrics ports. When false, only HTTP is configured.
Guidance¶
Feature: nil applies unconditionally. Omit Feature for mutations that should always run. Chain .When(bool) for
boolean conditions and pass version constraints to NewVersionGate for version-gated behavior.
Register mutations in dependency order. If mutation B depends on a port added by mutation A, register A first.
Use EnsurePort for idempotent port management. Ports are tracked by name (or port number when unnamed), so
repeated calls with the same name produce the same result.
Leave Services in place during suspension. The no-op default is correct for most Services. Only override
WithCustomSuspendDeletionDecision when your use case requires explicitly removing the Service during suspension.
Use WithDataExtractor for assigned addresses. ClusterIP and LoadBalancer ingress are server-assigned. Read them
with a data extractor after reconciliation rather than caching them in mutation logic.