The service primitive is the framework's built-in integration abstraction for managing Kubernetes Service resources.
It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a structured
mutation API for managing ports, selectors, and service configuration.
| 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 |
| Grace status | LoadBalancer with no ingress reports Degraded; non-LoadBalancer or has ingress is Healthy |
| 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 |
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 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:
func MyFeatureMutation(version string) service.Mutation {
return service.Mutation{
Name: "my-feature",
Feature: feature.NewVersionGate(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.
Use When(bool) to gate a mutation on a runtime 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
},
}
}Pass a []feature.VersionConstraint to gate on a semver range:
var legacyConstraint = mustSemverConstraint("< 2.0.0")
func LegacyPortMutation(version string) service.Mutation {
return service.Mutation{
Name: "legacy-port",
Feature: feature.NewVersionGate(
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.
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 |
Within each category, edits are applied in their registration order. Later features observe the Service as modified by all previous features.
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
})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.
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.EnsurePort(corev1.ServicePort{Name: "http", Port: 80})
e.RemovePort("legacy")
return nil
})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", "myapp")
e.EnsureSelector("env", "production")
return nil
})For fields not covered by the typed API, use Raw():
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
e.Raw().HealthCheckNodePort = 30000
return nil
})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
})The Service primitive implements the Operational concept to track whether the Service is ready to accept traffic.
| 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:
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()The default grace status handler inspects the Service type and load balancer status to assess 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
})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:
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.
Use WithDataExtractor to read values from the reconciled Service, 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()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
},
}
}
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.
Feature: nil applies unconditionally. Omit Feature (leave it nil) for mutations that should always run. Use
feature.NewVersionGate(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.