Skip to content

Commit a78f60d

Browse files
stubbiclaude
andcommitted
feat(resources): add BuildHTTPRoute builder
Build the desired gateway.networking.k8s.io/v1 HTTPRoute as an unstructured object (no sigs.k8s.io/gateway-api dependency), following the BuildServiceMonitor pattern. A single PathPrefix rule routes to the agent Service; the named service port is resolved to its port number for the backendRef. Pure-function unit tests cover the nil/basics/custom-port cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dc542dc commit a78f60d

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

internal/resources/httproute.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package resources
2+
3+
import (
4+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
5+
"k8s.io/apimachinery/pkg/runtime/schema"
6+
7+
hermesv1 "github.com/paperclipinc/hermes-operator/api/v1"
8+
)
9+
10+
// HTTPRouteGVK is the Gateway API GroupVersionKind we emit. We build it as an
11+
// unstructured object to avoid taking a dependency on sigs.k8s.io/gateway-api;
12+
// the CRDs must be installed in the cluster for the route to take effect.
13+
func HTTPRouteGVK() schema.GroupVersionKind {
14+
return schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "HTTPRoute"}
15+
}
16+
17+
// HTTPRouteName returns the deterministic HTTPRoute name.
18+
func HTTPRouteName(inst *hermesv1.HermesInstance) string { return inst.Name }
19+
20+
// servicePortNumber resolves a Service port name to its port number, mirroring
21+
// buildServicePorts. Falls back to GatewayPort when the name is not found.
22+
func servicePortNumber(inst *hermesv1.HermesInstance, name string) int32 {
23+
for _, p := range inst.Spec.Networking.Service.Ports {
24+
if p.Name == name {
25+
return p.Port
26+
}
27+
}
28+
if name == MetricsPortName {
29+
port := inst.Spec.Observability.Metrics.Port
30+
if port == 0 {
31+
return DefaultMetricsPort
32+
}
33+
return port
34+
}
35+
return GatewayPort
36+
}
37+
38+
// BuildHTTPRoute constructs the desired Gateway API HTTPRoute as an unstructured
39+
// object. It mirrors the Ingress builder: a single prefix rule routing to the
40+
// agent Service. Returns nil when no HTTPRoute is requested.
41+
func BuildHTTPRoute(inst *hermesv1.HermesInstance) *unstructured.Unstructured {
42+
spec := inst.Spec.Networking.HTTPRoute
43+
if spec == nil {
44+
return nil
45+
}
46+
47+
path := spec.Path
48+
if path == "" {
49+
path = "/"
50+
}
51+
portName := spec.ServicePortName
52+
if portName == "" {
53+
portName = GatewayPortName
54+
}
55+
// Gateway API backendRefs target a Service port by number, so resolve the
56+
// requested named port to the port number emitted on the Service.
57+
port := servicePortNumber(inst, portName)
58+
59+
parentRefs := make([]interface{}, 0, len(spec.ParentRefs))
60+
for _, ref := range spec.ParentRefs {
61+
pr := map[string]interface{}{
62+
"name": ref.Name,
63+
}
64+
if ref.Namespace != nil {
65+
pr["namespace"] = *ref.Namespace
66+
}
67+
if ref.SectionName != nil {
68+
pr["sectionName"] = *ref.SectionName
69+
}
70+
parentRefs = append(parentRefs, pr)
71+
}
72+
73+
hostnames := make([]interface{}, 0, len(spec.Hostnames))
74+
for _, h := range spec.Hostnames {
75+
hostnames = append(hostnames, h)
76+
}
77+
78+
metadata := map[string]interface{}{
79+
"name": HTTPRouteName(inst),
80+
"namespace": inst.Namespace,
81+
"labels": toIface(LabelsForInstance(inst)),
82+
}
83+
if len(spec.Annotations) > 0 {
84+
metadata["annotations"] = toIface(spec.Annotations)
85+
}
86+
87+
return &unstructured.Unstructured{
88+
Object: map[string]interface{}{
89+
"apiVersion": HTTPRouteGVK().GroupVersion().String(),
90+
"kind": HTTPRouteGVK().Kind,
91+
"metadata": metadata,
92+
"spec": map[string]interface{}{
93+
"parentRefs": parentRefs,
94+
"hostnames": hostnames,
95+
"rules": []interface{}{
96+
map[string]interface{}{
97+
"matches": []interface{}{
98+
map[string]interface{}{
99+
"path": map[string]interface{}{
100+
"type": "PathPrefix",
101+
"value": path,
102+
},
103+
},
104+
},
105+
"backendRefs": []interface{}{
106+
map[string]interface{}{
107+
"name": ServiceName(inst),
108+
"port": int64(port),
109+
},
110+
},
111+
},
112+
},
113+
},
114+
},
115+
}
116+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package resources
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
10+
hermesv1 "github.com/paperclipinc/hermes-operator/api/v1"
11+
)
12+
13+
func TestBuildHTTPRoute_NilWhenUnset(t *testing.T) {
14+
t.Parallel()
15+
inst := &hermesv1.HermesInstance{ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "agents"}}
16+
assert.Nil(t, BuildHTTPRoute(inst), "no HTTPRoute spec means no route")
17+
}
18+
19+
func TestBuildHTTPRoute_Basics(t *testing.T) {
20+
t.Parallel()
21+
ns := "gateways"
22+
section := "https"
23+
inst := &hermesv1.HermesInstance{
24+
ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "agents"},
25+
Spec: hermesv1.HermesInstanceSpec{
26+
Networking: hermesv1.NetworkingSpec{
27+
HTTPRoute: &hermesv1.HTTPRouteSpec{
28+
Enabled: Ptr(true),
29+
Hostnames: []string{"agent.example.com"},
30+
ParentRefs: []hermesv1.HTTPRouteParentRef{
31+
{Name: "public-gw", Namespace: &ns, SectionName: &section},
32+
},
33+
Annotations: map[string]string{"team": "platform"},
34+
},
35+
},
36+
},
37+
}
38+
39+
route := BuildHTTPRoute(inst)
40+
require.NotNil(t, route)
41+
assert.Equal(t, "gateway.networking.k8s.io/v1", route.GetAPIVersion())
42+
assert.Equal(t, "HTTPRoute", route.GetKind())
43+
assert.Equal(t, "demo", route.GetName())
44+
assert.Equal(t, "agents", route.GetNamespace())
45+
assert.Equal(t, "platform", route.GetAnnotations()["team"])
46+
47+
spec, _, _ := getNestedMap(route.Object, "spec")
48+
49+
parents := spec["parentRefs"].([]interface{})
50+
require.Len(t, parents, 1)
51+
pr := parents[0].(map[string]interface{})
52+
assert.Equal(t, "public-gw", pr["name"])
53+
assert.Equal(t, "gateways", pr["namespace"])
54+
assert.Equal(t, "https", pr["sectionName"])
55+
56+
hostnames := spec["hostnames"].([]interface{})
57+
assert.Equal(t, "agent.example.com", hostnames[0])
58+
59+
rules := spec["rules"].([]interface{})
60+
require.Len(t, rules, 1)
61+
rule := rules[0].(map[string]interface{})
62+
63+
match := rule["matches"].([]interface{})[0].(map[string]interface{})
64+
path := match["path"].(map[string]interface{})
65+
assert.Equal(t, "PathPrefix", path["type"])
66+
assert.Equal(t, "/", path["value"])
67+
68+
backend := rule["backendRefs"].([]interface{})[0].(map[string]interface{})
69+
assert.Equal(t, "demo", backend["name"])
70+
assert.Equal(t, int64(GatewayPort), backend["port"], "backendRefs target the Service port by number")
71+
}
72+
73+
func TestBuildHTTPRoute_CustomPathAndPort(t *testing.T) {
74+
t.Parallel()
75+
inst := &hermesv1.HermesInstance{
76+
ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "agents"},
77+
Spec: hermesv1.HermesInstanceSpec{
78+
Networking: hermesv1.NetworkingSpec{
79+
Service: hermesv1.ServiceSpec{
80+
Ports: []hermesv1.NamedServicePort{{Name: "web", Port: 9000}},
81+
},
82+
HTTPRoute: &hermesv1.HTTPRouteSpec{
83+
Enabled: Ptr(true),
84+
Path: "/api",
85+
ServicePortName: "web",
86+
},
87+
},
88+
},
89+
}
90+
route := BuildHTTPRoute(inst)
91+
require.NotNil(t, route)
92+
spec, _, _ := getNestedMap(route.Object, "spec")
93+
rule := spec["rules"].([]interface{})[0].(map[string]interface{})
94+
path := rule["matches"].([]interface{})[0].(map[string]interface{})["path"].(map[string]interface{})
95+
assert.Equal(t, "/api", path["value"])
96+
backend := rule["backendRefs"].([]interface{})[0].(map[string]interface{})
97+
assert.Equal(t, int64(9000), backend["port"], "named port resolves to its Service port number")
98+
}
99+
100+
func TestHTTPRouteName(t *testing.T) {
101+
t.Parallel()
102+
inst := &hermesv1.HermesInstance{ObjectMeta: metav1.ObjectMeta{Name: "demo"}}
103+
assert.Equal(t, "demo", HTTPRouteName(inst))
104+
}

0 commit comments

Comments
 (0)