Skip to content

Commit 43ff3f8

Browse files
committed
feat(resources): Service builder honors spec.networking.service + auto-emits metrics port
1 parent 0493bdb commit 43ff3f8

2 files changed

Lines changed: 169 additions & 32 deletions

File tree

internal/resources/service.go

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,97 @@
11
package resources
22

33
import (
4+
hermesv1 "github.com/stubbi/hermes-operator/api/v1"
45
corev1 "k8s.io/api/core/v1"
56
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
67
"k8s.io/apimachinery/pkg/util/intstr"
7-
8-
hermesv1 "github.com/stubbi/hermes-operator/api/v1"
98
)
109

11-
// ServiceName returns the deterministic Service name for a HermesInstance.
10+
// ServiceName returns the deterministic Service name.
1211
func ServiceName(inst *hermesv1.HermesInstance) string { return inst.Name }
1312

14-
// BuildService returns the desired headless Service. Headless = stable DNS
15-
// for the StatefulSet pod. Plan 2 adds optional ClusterIP / LoadBalancer modes.
13+
// BuildService constructs the desired Service. Honors spec.networking.service
14+
// (Type, ClusterIP, Ports, Annotations, LoadBalancerClass, ExternalTrafficPolicy);
15+
// appends a "metrics" port automatically when spec.observability.metrics.enabled.
1616
func BuildService(inst *hermesv1.HermesInstance) *corev1.Service {
17+
ss := inst.Spec.Networking.Service
18+
19+
svcType := ss.Type
20+
if svcType == "" {
21+
svcType = corev1.ServiceTypeClusterIP
22+
}
23+
24+
ports := buildServicePorts(inst)
25+
1726
return &corev1.Service{
1827
ObjectMeta: metav1.ObjectMeta{
19-
Name: ServiceName(inst),
20-
Namespace: inst.Namespace,
21-
Labels: LabelsForInstance(inst),
28+
Name: ServiceName(inst),
29+
Namespace: inst.Namespace,
30+
Labels: LabelsForInstance(inst),
31+
Annotations: ss.Annotations,
2232
},
2333
Spec: corev1.ServiceSpec{
24-
ClusterIP: corev1.ClusterIPNone,
25-
SessionAffinity: corev1.ServiceAffinityNone, // explicit k8s default
26-
Selector: map[string]string{
27-
"app.kubernetes.io/name": "hermes-agent",
28-
"app.kubernetes.io/instance": inst.Name,
29-
},
30-
Ports: []corev1.ServicePort{
31-
{
32-
Name: "gateway",
33-
Port: 8443,
34-
TargetPort: intstr.FromString("gateway"),
35-
Protocol: corev1.ProtocolTCP,
36-
},
37-
},
34+
Type: svcType,
35+
ClusterIP: ss.ClusterIP,
36+
SessionAffinity: corev1.ServiceAffinityNone,
37+
Selector: SelectorLabels(inst),
38+
Ports: ports,
39+
LoadBalancerClass: ss.LoadBalancerClass,
40+
ExternalTrafficPolicy: ss.ExternalTrafficPolicy,
3841
},
3942
}
4043
}
44+
45+
func buildServicePorts(inst *hermesv1.HermesInstance) []corev1.ServicePort {
46+
ports := []corev1.ServicePort{}
47+
custom := inst.Spec.Networking.Service.Ports
48+
if len(custom) > 0 {
49+
for _, p := range custom {
50+
protocol := p.Protocol
51+
if protocol == "" {
52+
protocol = corev1.ProtocolTCP
53+
}
54+
target := p.Port
55+
if p.TargetPort != nil {
56+
target = *p.TargetPort
57+
}
58+
sp := corev1.ServicePort{
59+
Name: p.Name,
60+
Port: p.Port,
61+
TargetPort: intstr.FromInt32(target),
62+
Protocol: protocol,
63+
NodePort: p.NodePort,
64+
}
65+
ports = append(ports, sp)
66+
}
67+
} else {
68+
ports = append(ports, corev1.ServicePort{
69+
Name: GatewayPortName,
70+
Port: GatewayPort,
71+
TargetPort: intstr.FromString(GatewayPortName),
72+
Protocol: corev1.ProtocolTCP,
73+
})
74+
}
75+
if BoolValueOrDefault(inst.Spec.Observability.Metrics.Enabled, true) {
76+
port := inst.Spec.Observability.Metrics.Port
77+
if port == 0 {
78+
port = DefaultMetricsPort
79+
}
80+
seen := false
81+
for _, p := range ports {
82+
if p.Name == MetricsPortName {
83+
seen = true
84+
break
85+
}
86+
}
87+
if !seen {
88+
ports = append(ports, corev1.ServicePort{
89+
Name: MetricsPortName,
90+
Port: port,
91+
TargetPort: intstr.FromString(MetricsPortName),
92+
Protocol: corev1.ProtocolTCP,
93+
})
94+
}
95+
}
96+
return ports
97+
}

internal/resources/service_test.go

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,101 @@ import (
44
"testing"
55

66
"github.com/stretchr/testify/assert"
7+
hermesv1 "github.com/stubbi/hermes-operator/api/v1"
78
corev1 "k8s.io/api/core/v1"
89
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9-
10-
hermesv1 "github.com/stubbi/hermes-operator/api/v1"
1110
)
1211

13-
func TestBuildService_DefaultsAndSelector(t *testing.T) {
14-
inst := &hermesv1.HermesInstance{
15-
ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "agents"},
16-
}
12+
func TestBuildService_DefaultClusterIPWithGatewayPort(t *testing.T) {
13+
t.Parallel()
14+
inst := &hermesv1.HermesInstance{ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "agents"}}
1715
svc := BuildService(inst)
1816
assert.Equal(t, "demo", svc.Name)
19-
assert.Equal(t, corev1.ClusterIPNone, svc.Spec.ClusterIP, "headless Service for StatefulSet")
20-
assert.Equal(t, corev1.ServiceAffinityNone, svc.Spec.SessionAffinity, "explicit k8s default")
17+
assert.Equal(t, corev1.ServiceTypeClusterIP, svc.Spec.Type)
18+
assert.Equal(t, corev1.ServiceAffinityNone, svc.Spec.SessionAffinity)
2119
assert.Equal(t, "demo", svc.Spec.Selector["app.kubernetes.io/instance"])
22-
// Must declare at least the gateway port so the Service is valid.
23-
assert.NotEmpty(t, svc.Spec.Ports)
20+
require := false
21+
for _, p := range svc.Spec.Ports {
22+
if p.Name == "gateway" && p.Port == 8443 {
23+
require = true
24+
}
25+
}
26+
assert.True(t, require, "default gateway port on 8443")
27+
}
28+
29+
func TestBuildService_Headless(t *testing.T) {
30+
t.Parallel()
31+
inst := &hermesv1.HermesInstance{
32+
ObjectMeta: metav1.ObjectMeta{Name: "demo"},
33+
Spec: hermesv1.HermesInstanceSpec{
34+
Networking: hermesv1.NetworkingSpec{
35+
Service: hermesv1.ServiceSpec{Type: corev1.ServiceTypeClusterIP, ClusterIP: corev1.ClusterIPNone},
36+
},
37+
},
38+
}
39+
svc := BuildService(inst)
40+
assert.Equal(t, corev1.ClusterIPNone, svc.Spec.ClusterIP)
41+
}
42+
43+
func TestBuildService_LoadBalancerAnnotations(t *testing.T) {
44+
t.Parallel()
45+
inst := &hermesv1.HermesInstance{
46+
ObjectMeta: metav1.ObjectMeta{Name: "demo"},
47+
Spec: hermesv1.HermesInstanceSpec{
48+
Networking: hermesv1.NetworkingSpec{
49+
Service: hermesv1.ServiceSpec{
50+
Type: corev1.ServiceTypeLoadBalancer,
51+
Annotations: map[string]string{"foo": "bar"},
52+
ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyTypeLocal,
53+
},
54+
},
55+
},
56+
}
57+
svc := BuildService(inst)
58+
assert.Equal(t, corev1.ServiceTypeLoadBalancer, svc.Spec.Type)
59+
assert.Equal(t, "bar", svc.Annotations["foo"])
60+
assert.Equal(t, corev1.ServiceExternalTrafficPolicyTypeLocal, svc.Spec.ExternalTrafficPolicy)
61+
}
62+
63+
func TestBuildService_CustomPorts(t *testing.T) {
64+
t.Parallel()
65+
tp := int32(8443)
66+
inst := &hermesv1.HermesInstance{
67+
ObjectMeta: metav1.ObjectMeta{Name: "demo"},
68+
Spec: hermesv1.HermesInstanceSpec{
69+
Networking: hermesv1.NetworkingSpec{
70+
Service: hermesv1.ServiceSpec{
71+
Ports: []hermesv1.NamedServicePort{
72+
{Name: "gateway", Port: 443, TargetPort: &tp, Protocol: corev1.ProtocolTCP},
73+
},
74+
},
75+
},
76+
Observability: hermesv1.ObservabilitySpec{
77+
Metrics: hermesv1.MetricsSpec{Enabled: Ptr(false)},
78+
},
79+
},
80+
}
81+
svc := BuildService(inst)
82+
assert.Len(t, svc.Spec.Ports, 1)
83+
assert.Equal(t, int32(443), svc.Spec.Ports[0].Port)
84+
}
85+
86+
func TestBuildService_AddsMetricsPortWhenEnabled(t *testing.T) {
87+
t.Parallel()
88+
inst := &hermesv1.HermesInstance{
89+
ObjectMeta: metav1.ObjectMeta{Name: "demo"},
90+
Spec: hermesv1.HermesInstanceSpec{
91+
Observability: hermesv1.ObservabilitySpec{
92+
Metrics: hermesv1.MetricsSpec{Enabled: Ptr(true), Port: 9090},
93+
},
94+
},
95+
}
96+
svc := BuildService(inst)
97+
var sawMetrics bool
98+
for _, p := range svc.Spec.Ports {
99+
if p.Name == "metrics" && p.Port == 9090 {
100+
sawMetrics = true
101+
}
102+
}
103+
assert.True(t, sawMetrics, "metrics port emitted when Metrics.Enabled")
24104
}

0 commit comments

Comments
 (0)