Files
weave/lb/gateway_test.go
levaliciousandClaude Opus 5.5 7405b51e53
images / images (push) Successful in 6m32s
lb: serve Gateways (Gateway API v1) from the node holding their address
Gateways of a GatewayClass whose controllerName is reweave.levk.dev/gateway
are served like LoadBalancer Services: a Gateway names one IPv4 address in
spec.addresses, and the node holding that address serves all of it and
writes its status.

- TCP listeners forward the oldest TCPRoute attached to them in the kernel,
  through the same DNAT and reply steering as LoadBalancer Services, so the
  backend sees the client's own address.
- HTTP and HTTPS listeners are served by the agent itself on the holder:
  TLS terminated with the listener's certificate (chosen by SNI, swapped in
  place when its Secret changes, HTTP/2 offered), requests routed by the
  attached HTTPRoutes in the order the spec prescribes (hostname, exact
  path, longest prefix, method, headers, query, age, name, rule), and passed
  to a ready endpoint of the backend with X-Forwarded-For built from this
  hop only. RequestRedirect, URLRewrite and both header modifiers are
  implemented; other filters and regular expressions are refused
  (Accepted False, UnsupportedValue). Bodies are streamed, never limited.
  Unresolved backends answer 500, backends without ready endpoints 503,
  requests nothing matches 404. Those ports are let in ahead of the host
  firewall (REWEAVE-LB-IN).

Listeners are checked for protocol, route kinds, certificates (Secrets in
the Gateway's namespace or granted by a ReferenceGrant, and only in the
namespaces the agent may read: --gateway-namespaces, default
gateway-system), conflicts on a port, and ports LoadBalancer Services
already forward. Routes attach by parentRef (sectionName, port),
allowedRoutes (namespaces Same, All or Selector; kinds) and hostname
intersection, with the spec's conditions and reasons.

One writer per status: the holder writes its Gateway's (the first node by
name when nobody holds the address, so failures are reported once), the
first node by name writes GatewayClass and route statuses, keeping other
controllers' route parents. Writes are conditional on the resourceVersion
they were computed from, and a condition keeps its transition time while
its status stands, so an unchanged status is never rewritten.

The types are plain structs for the subset implemented (the upstream module
needs a newer client-go than the fork's); the CRDs are installed apart from
this manifest, which grants the agent read access to the Gateway API kinds,
patch on their status, and Secrets in gateway-system only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 23:51:44 -04:00

577 lines
23 KiB
Go

package lb
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"strings"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
discovery "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var gwNow = metav1.NewTime(time.Date(2026, 10, 14, 4, 0, 0, 0, time.UTC))
// selfSigned is a certificate and key for the given names, PEM-encoded.
func selfSigned(t *testing.T, names ...string) ([]byte, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
kb, _ := x509.MarshalECPrivateKey(key)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})
}
func tlsSecret(t *testing.T, namespace, name string, names ...string) *coreapi.Secret {
crt, key := selfSigned(t, names...)
return &coreapi.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Type: coreapi.SecretTypeTLS,
Data: map[string][]byte{coreapi.TLSCertKey: crt, coreapi.TLSPrivateKeyKey: key}}
}
func gwNodes() []Node {
return []Node{
{Name: "cp", Addresses: []net.IP{net.ParseIP("86.107.168.24").To4()}, WeaveIP: net.ParseIP("10.32.0.1").To4(), WeaveMAC: mustMAC("02:00:00:00:00:01")},
{Name: "w15", Addresses: []net.IP{net.ParseIP("86.107.168.15").To4()}, WeaveIP: net.ParseIP("10.44.0.0").To4(), WeaveMAC: mustMAC("02:00:00:00:00:02")},
}
}
func mustMAC(s string) net.HardwareAddr {
m, _ := net.ParseMAC(s)
return m
}
func ourClass() *GatewayClass {
return &GatewayClass{ObjectMeta: metav1.ObjectMeta{Name: "reweave", Generation: 1}, Spec: GatewayClassSpec{ControllerName: GatewayControllerName}}
}
// giteaGateway is the shape Gitea's documentation prescribes: HTTPS with a certificate, plain HTTP, and SSH over TCP.
func giteaGateway() *Gateway {
return &Gateway{
ObjectMeta: metav1.ObjectMeta{Namespace: "gateway-system", Name: "shared", Generation: 3},
Spec: GatewaySpec{
GatewayClassName: "reweave",
Addresses: []GatewayAddress{{Value: "86.107.168.15"}},
Listeners: []Listener{
{Name: "https-gitea", Port: 443, Protocol: ProtocolHTTPS, Hostname: ptr("git.levk.dev"),
TLS: &ListenerTLSConfig{CertificateRefs: []SecretObjectReference{{Name: "git-levk-dev"}}},
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("Selector"), Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "gitea"}}}}},
{Name: "http", Port: 80, Protocol: ProtocolHTTP, Hostname: ptr("git.levk.dev"),
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("All")}}},
{Name: "ssh", Port: 22, Protocol: ProtocolTCP,
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("All")}}},
},
},
}
}
func giteaService() *coreapi.Service {
return &coreapi.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea"}, Spec: coreapi.ServiceSpec{
Ports: []coreapi.ServicePort{{Name: "http", Port: 3000}, {Name: "ssh", Port: 22}},
}}
}
func giteaSlice() *discovery.EndpointSlice {
return &discovery.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-abc", Labels: map[string]string{serviceNameLabel: "gitea"}},
AddressType: discovery.AddressTypeIPv4,
Ports: []discovery.EndpointPort{{Name: ptr("http"), Port: ptr(int32(3000))}, {Name: ptr("ssh"), Port: ptr(int32(22))}},
Endpoints: []discovery.Endpoint{{Addresses: []string{"10.44.0.12"}, Conditions: discovery.EndpointConditions{Ready: ptr(true)}}},
}
}
func giteaRoutes() ([]*HTTPRoute, []*TCPRoute) {
t0 := metav1.NewTime(time.Date(2026, 10, 14, 1, 0, 0, 0, time.UTC))
https := &HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea", Generation: 1, CreationTimestamp: t0},
Spec: HTTPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("https-gitea")}},
Hostnames: []string{"git.levk.dev"},
Rules: []HTTPRouteRule{{BackendRefs: []HTTPBackendRef{{BackendRef: BackendRef{Name: "gitea", Port: ptr(int32(3000))}}}}},
},
}
redirect := &HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-redirect", Generation: 1, CreationTimestamp: t0},
Spec: HTTPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("http")}},
Rules: []HTTPRouteRule{{Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Scheme: ptr("https"), StatusCode: ptr(301)}}}}},
},
}
ssh := &TCPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-ssh", Generation: 1, CreationTimestamp: t0},
Spec: TCPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("ssh")}},
Rules: []TCPRouteRule{{BackendRefs: []BackendRef{{Name: "gitea", Port: ptr(int32(22))}}}},
},
}
return []*HTTPRoute{https, redirect}, []*TCPRoute{ssh}
}
func giteaInput(t *testing.T) GatewayInput {
hr, tr := giteaRoutes()
return GatewayInput{
Classes: []*GatewayClass{ourClass()}, Gateways: []*Gateway{giteaGateway()}, HTTPRoutes: hr, TCPRoutes: tr,
Namespaces: []*coreapi.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "gitea", Labels: map[string]string{"kubernetes.io/metadata.name": "gitea"}}},
{ObjectMeta: metav1.ObjectMeta{Name: "gateway-system", Labels: map[string]string{"kubernetes.io/metadata.name": "gateway-system"}}},
},
Services: []*coreapi.Service{giteaService()}, Slices: []*discovery.EndpointSlice{giteaSlice()},
Secrets: []*coreapi.Secret{tlsSecret(t, "gateway-system", "git-levk-dev", "git.levk.dev")},
SecretNamespaces: map[string]bool{"gateway-system": true},
Now: gwNow,
}
}
func condOf(cs []metav1.Condition, typ string) metav1.Condition {
for _, c := range cs {
if c.Type == typ {
return c
}
}
return metav1.Condition{}
}
func TestGatewayServedByItsHolder(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("w15", gwNodes(), in)
if len(d.Problems) != 0 {
t.Fatalf("problems: %v", d.Problems)
}
// HTTPS and HTTP served by the agent; SSH forwarded in the kernel to Gitea's pod with the client address intact
if len(d.Serve) != 2 {
t.Fatalf("want the https and http listeners served, got %+v", d.Serve)
}
https := d.Serve[0]
if !https.HTTPS || https.Port != 443 || https.Hostname != "git.levk.dev" || https.Cert == nil || len(https.Routes) != 1 {
t.Fatalf("https listener: %+v", https)
}
be := https.Routes[0].Rules[0].Backends
if len(be) != 1 || be[0].Key != "gitea/gitea:3000" || len(be[0].Endpoints) != 1 || be[0].Endpoints[0].String() != "10.44.0.12:3000" {
t.Fatalf("https backends: %+v", be)
}
if len(d.Forwards) != 1 || d.Forwards[0].Ports[0].Port != 22 || d.Forwards[0].Ports[0].Endpoints[0].String() != "10.44.0.12:22" {
t.Fatalf("ssh forward: %+v", d.Forwards)
}
if len(d.Holding) != 1 || d.Holding[0] != "w15" {
t.Fatalf("holding: %v", d.Holding)
}
// the holder writes the Gateway's status: accepted, programmed, its address, each listener with one route
if len(d.Gateways) != 1 {
t.Fatalf("gateway status writes: %+v", d.Gateways)
}
st := d.Gateways[0].Status
if c := condOf(st.Conditions, condProgrammed); c.Status != metav1.ConditionTrue || c.ObservedGeneration != 3 {
t.Fatalf("programmed: %+v", c)
}
if len(st.Addresses) != 1 || st.Addresses[0].Value != "86.107.168.15" {
t.Fatalf("addresses: %+v", st.Addresses)
}
for _, l := range st.Listeners {
if l.AttachedRoutes != 1 || condOf(l.Conditions, condProgrammed).Status != metav1.ConditionTrue {
t.Fatalf("listener %s: %+v", l.Name, l)
}
}
// route statuses come from the first node by name, not the holder
if len(d.Routes) != 0 || len(d.Classes) != 0 {
t.Fatalf("w15 wrote route or class statuses: %+v %+v", d.Routes, d.Classes)
}
}
func TestGatewayElsewhere(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("cp", gwNodes(), in)
if len(d.Serve) != 0 || len(d.Forwards) != 0 || len(d.Gateways) != 0 {
t.Fatalf("cp serves or writes what w15 holds: %+v %+v %+v", d.Serve, d.Forwards, d.Gateways)
}
// but steers replies of w15's forwards back to it, and writes class and route statuses (first by name)
if len(d.Holding) != 1 || d.Holding[0] != "w15" {
t.Fatalf("holding: %v", d.Holding)
}
if len(d.Classes) != 1 || condOf(d.Classes[0].Status.Conditions, condAccepted).Status != metav1.ConditionTrue {
t.Fatalf("class status: %+v", d.Classes)
}
if len(d.Routes) != 3 {
t.Fatalf("want 3 route statuses, got %+v", d.Routes)
}
for _, r := range d.Routes {
if len(r.Status.Parents) != 1 || condOf(r.Status.Parents[0].Conditions, condAccepted).Status != metav1.ConditionTrue ||
condOf(r.Status.Parents[0].Conditions, condResolvedRefs).Status != metav1.ConditionTrue {
t.Fatalf("route %s/%s status: %+v", r.Namespace, r.Name, r.Status)
}
}
}
func TestGatewayStatusIsWrittenOnce(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("w15", gwNodes(), in)
in.Gateways[0].Status = d.Gateways[0].Status
in.Now = metav1.NewTime(gwNow.Add(time.Hour)) // later passes see the status they wrote
if again := ComputeGateways("w15", gwNodes(), in); len(again.Gateways) != 0 {
t.Fatalf("unchanged status rewritten: %+v", again.Gateways)
}
c := ComputeGateways("cp", gwNodes(), in)
for _, r := range c.Routes {
for _, hr := range in.HTTPRoutes {
if hr.Name == r.Name {
hr.Status = r.Status
}
}
for _, tr := range in.TCPRoutes {
if tr.Name == r.Name {
tr.Status = r.Status
}
}
}
in.Classes[0].Status = c.Classes[0].Status
if again := ComputeGateways("cp", gwNodes(), in); len(again.Routes) != 0 || len(again.Classes) != 0 {
t.Fatalf("unchanged route or class status rewritten: %+v %+v", again.Routes, again.Classes)
}
}
func TestGatewayFailsClosedWithoutHolder(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Addresses[0].Value = "198.51.100.7"
for _, self := range []string{"cp", "w15"} {
d := ComputeGateways(self, gwNodes(), in)
if len(d.Serve) != 0 || len(d.Forwards) != 0 || len(d.Holding) != 0 {
t.Fatalf("%s serves an address nobody holds: %+v", self, d)
}
}
d := ComputeGateways("cp", gwNodes(), in)
if len(d.Gateways) != 1 {
t.Fatalf("the first node by name must write the failure: %+v", d.Gateways)
}
c := condOf(d.Gateways[0].Status.Conditions, condProgrammed)
if c.Status != metav1.ConditionFalse || c.Reason != "AddressNotUsable" || len(d.Gateways[0].Status.Addresses) != 0 {
t.Fatalf("status: %+v", d.Gateways[0].Status)
}
}
func TestGatewayAddressMustBeOneIPv4(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Addresses = nil
d := ComputeGateways("cp", gwNodes(), in)
if c := condOf(d.Gateways[0].Status.Conditions, condAccepted); c.Status != metav1.ConditionFalse || c.Reason != "UnsupportedAddress" {
t.Fatalf("accepted: %+v", c)
}
}
func TestListenerCertificateReferences(t *testing.T) {
cases := map[string]struct {
mutate func(in *GatewayInput)
reason string
}{
"missing Secret": {func(in *GatewayInput) { in.Secrets = nil }, "InvalidCertificateRef"},
"other namespace without grant": {func(in *GatewayInput) {
in.Gateways[0].Spec.Listeners[0].TLS.CertificateRefs[0].Namespace = ptr("gitea")
in.SecretNamespaces["gitea"] = true
}, "RefNotPermitted"},
"namespace the agent cannot read": {func(in *GatewayInput) {
in.SecretNamespaces = map[string]bool{}
}, "RefNotPermitted"},
"not a certificate": {func(in *GatewayInput) {
in.Secrets[0].Data[coreapi.TLSCertKey] = []byte("nope")
}, "InvalidCertificateRef"},
}
for name, c := range cases {
in := giteaInput(t)
c.mutate(&in)
d := ComputeGateways("w15", gwNodes(), in)
var https ListenerStatus
for _, l := range d.Gateways[0].Status.Listeners {
if l.Name == "https-gitea" {
https = l
}
}
if r := condOf(https.Conditions, condResolvedRefs); r.Status != metav1.ConditionFalse || r.Reason != c.reason {
t.Errorf("%s: resolvedRefs %+v", name, r)
}
if p := condOf(https.Conditions, condProgrammed); p.Status != metav1.ConditionFalse {
t.Errorf("%s: an HTTPS listener without its certificate must not be programmed", name)
}
for _, s := range d.Serve {
if s.HTTPS {
t.Errorf("%s: HTTPS listener served without a certificate", name)
}
}
if a := condOf(d.Gateways[0].Status.Conditions, condAccepted); a.Reason != "ListenersNotValid" || a.Status != metav1.ConditionTrue {
t.Errorf("%s: gateway accepted %+v", name, a)
}
}
}
func TestCrossNamespaceCertificateWithGrant(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Listeners[0].TLS.CertificateRefs[0].Namespace = ptr("gitea")
in.Secrets = []*coreapi.Secret{tlsSecret(t, "gitea", "git-levk-dev", "git.levk.dev")}
in.SecretNamespaces = map[string]bool{"gitea": true}
in.Grants = []*ReferenceGrant{{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gw"}, Spec: ReferenceGrantSpec{
From: []ReferenceGrantFrom{{Group: GatewayGroup, Kind: kindGateway, Namespace: "gateway-system"}},
To: []ReferenceGrantTo{{Group: "", Kind: kindSecret}},
}}}
d := ComputeGateways("w15", gwNodes(), in)
if len(d.Serve) != 2 || d.Serve[0].Cert == nil {
t.Fatalf("a granted certificate in another namespace must be served: %+v", d.Serve)
}
}
func TestListenerConflicts(t *testing.T) {
in := giteaInput(t)
l := in.Gateways[0].Spec.Listeners
in.Gateways[0].Spec.Listeners = append(l,
Listener{Name: "ssh2", Port: 22, Protocol: ProtocolTCP}, // two TCP listeners on 22
Listener{Name: "http-on-443", Port: 443, Protocol: ProtocolHTTP}, // HTTP and HTTPS on 443
)
d := ComputeGateways("w15", gwNodes(), in)
want := map[string]string{"ssh": "HostnameConflict", "ssh2": "HostnameConflict", "https-gitea": "ProtocolConflict", "http-on-443": "ProtocolConflict", "http": ""}
for _, ls := range d.Gateways[0].Status.Listeners {
c := condOf(ls.Conditions, condConflicted)
if want[ls.Name] == "" {
if c.Status != metav1.ConditionFalse {
t.Errorf("%s: conflicted %+v", ls.Name, c)
}
continue
}
if c.Status != metav1.ConditionTrue || c.Reason != want[ls.Name] {
t.Errorf("%s: want conflicted %s, got %+v", ls.Name, want[ls.Name], c)
}
}
if len(d.Forwards) != 0 {
t.Fatalf("conflicted TCP listeners forwarded: %+v", d.Forwards)
}
if len(d.Serve) != 1 || d.Serve[0].Name != "http" {
t.Fatalf("only the plain http listener on 80 is unconflicted: %+v", d.Serve)
}
}
func TestListenerPortTakenByLoadBalancerService(t *testing.T) {
in := giteaInput(t)
class := Class
in.Services = append(in.Services, &coreapi.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "mail", Name: "smtp"}, Spec: coreapi.ServiceSpec{
Type: coreapi.ServiceTypeLoadBalancer, LoadBalancerClass: &class, LoadBalancerIP: "86.107.168.15",
Ports: []coreapi.ServicePort{{Port: 22}},
}})
d := ComputeGateways("w15", gwNodes(), in)
for _, ls := range d.Gateways[0].Status.Listeners {
if ls.Name == "ssh" {
if c := condOf(ls.Conditions, condAccepted); c.Status != metav1.ConditionFalse || c.Reason != "PortUnavailable" {
t.Fatalf("ssh accepted: %+v", c)
}
}
}
if len(d.Forwards) != 0 {
t.Fatalf("a listener on a port a Service forwards must not be forwarded: %+v", d.Forwards)
}
}
func TestRouteAttachment(t *testing.T) {
cases := map[string]struct {
mutate func(in *GatewayInput)
route string
reason string
}{
"unknown section": {func(in *GatewayInput) { in.HTTPRoutes[0].Spec.ParentRefs[0].SectionName = ptr("nope") }, "gitea", "NoMatchingParent"},
"namespace not selected": {func(in *GatewayInput) { in.Namespaces[0].Labels = nil }, "gitea", "NotAllowedByListeners"},
"hostname outside the listener's": {func(in *GatewayInput) { in.HTTPRoutes[0].Spec.Hostnames = []string{"git.example.net"} }, "gitea", "NoMatchingListenerHostname"},
"unsupported filter": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: "RequestMirror"}}
}, "gitea", "UnsupportedValue"},
"regular expression path": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Matches = []HTTPRouteMatch{{Path: &HTTPPathMatch{Type: ptr("RegularExpression"), Value: ptr("/.*")}}}
}, "gitea", "UnsupportedValue"},
"wrong port": {func(in *GatewayInput) { in.TCPRoutes[0].Spec.ParentRefs[0].Port = ptr(int32(2222)) }, "gitea-ssh", "NoMatchingParent"},
}
for name, c := range cases {
in := giteaInput(t)
c.mutate(&in)
d := ComputeGateways("cp", gwNodes(), in)
found := false
for _, r := range d.Routes {
if r.Name != c.route {
continue
}
found = true
a := condOf(r.Status.Parents[0].Conditions, condAccepted)
if a.Status != metav1.ConditionFalse || a.Reason != c.reason {
t.Errorf("%s: accepted %+v", name, a)
}
}
if !found {
t.Errorf("%s: no status for route %s", name, c.route)
}
h := ComputeGateways("w15", gwNodes(), in)
if c.route == "gitea" {
for _, s := range h.Serve {
if s.Name == "https-gitea" && len(s.Routes) != 0 {
t.Errorf("%s: a route that is not accepted was attached", name)
}
}
}
}
}
func TestBackendNotFoundBreaksTheRule(t *testing.T) {
in := giteaInput(t)
in.HTTPRoutes[0].Spec.Rules[0].BackendRefs[0].Name = "nope"
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
if r.Name == "gitea" {
if c := condOf(r.Status.Parents[0].Conditions, condResolvedRefs); c.Status != metav1.ConditionFalse || c.Reason != "BackendNotFound" {
t.Fatalf("resolvedRefs: %+v", c)
}
if c := condOf(r.Status.Parents[0].Conditions, condAccepted); c.Status != metav1.ConditionTrue {
t.Fatalf("a route with an unresolved backend is still accepted: %+v", c)
}
}
}
h := ComputeGateways("w15", gwNodes(), in)
for _, s := range h.Serve {
if s.Name == "https-gitea" && (len(s.Routes) != 1 || !s.Routes[0].Rules[0].Broken) {
t.Fatalf("the rule must be served broken (500): %+v", s.Routes)
}
}
}
func TestTCPListenerForwardsTheOldestRoute(t *testing.T) {
in := giteaInput(t)
later := in.TCPRoutes[0].DeepCopyObject().(*TCPRoute)
later.Name = "other-ssh"
later.CreationTimestamp = metav1.NewTime(later.CreationTimestamp.Add(time.Minute))
later.Spec.Rules[0].BackendRefs[0].Name = "other"
in.TCPRoutes = append(in.TCPRoutes, later)
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
a := condOf(r.Status.Parents[0].Conditions, condAccepted)
switch r.Name {
case "gitea-ssh":
if a.Status != metav1.ConditionTrue {
t.Fatalf("oldest route: %+v", a)
}
case "other-ssh":
if a.Status != metav1.ConditionFalse || !strings.Contains(a.Message, "gitea/gitea-ssh") {
t.Fatalf("second route: %+v", a)
}
}
}
}
func TestOtherControllersParentsKept(t *testing.T) {
in := giteaInput(t)
theirs := RouteParentStatus{ParentRef: ParentReference{Name: "elsewhere"}, ControllerName: "example.com/other",
Conditions: []metav1.Condition{{Type: condAccepted, Status: metav1.ConditionTrue, Reason: "Accepted", LastTransitionTime: gwNow}}}
in.HTTPRoutes[0].Status.Parents = []RouteParentStatus{theirs}
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
if r.Name == "gitea" {
if len(r.Status.Parents) != 2 || r.Status.Parents[0].ControllerName != "example.com/other" {
t.Fatalf("another controller's entry must be kept: %+v", r.Status.Parents)
}
}
}
}
func TestOtherClassesIgnored(t *testing.T) {
in := giteaInput(t)
in.Classes[0].Spec.ControllerName = "example.com/other"
for _, self := range []string{"cp", "w15"} {
d := ComputeGateways(self, gwNodes(), in)
if len(d.Serve)+len(d.Forwards)+len(d.Gateways)+len(d.Routes)+len(d.Classes) != 0 {
t.Fatalf("%s acted on another controller's class: %+v", self, d)
}
}
}
func TestNarrowHostnames(t *testing.T) {
cases := []struct {
listener string
route []string
want []string
ok bool
}{
{"", nil, nil, true},
{"git.levk.dev", nil, []string{"git.levk.dev"}, true},
{"", []string{"a.example.com"}, []string{"a.example.com"}, true},
{"test.example.com", []string{"*.example.com"}, []string{"test.example.com"}, true},
{"*.example.com", []string{"test.example.com", "test.example.net", "foo.test.example.com"}, []string{"test.example.com", "foo.test.example.com"}, true},
{"*.example.com", []string{"example.com"}, nil, false},
{"*.example.com", []string{"*.a.example.com"}, []string{"*.a.example.com"}, true},
{"*.a.example.com", []string{"*.example.com"}, []string{"*.a.example.com"}, true},
{"git.levk.dev", []string{"git.example.net"}, nil, false},
}
for _, c := range cases {
got, ok := narrowHostnames(c.listener, c.route)
if ok != c.ok || strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("listener %q route %v: got %v %v, want %v %v", c.listener, c.route, got, ok, c.want, c.ok)
}
}
}
func TestMergeGateways(t *testing.T) {
in := giteaInput(t)
nodes := gwNodes()
// on the holder: Gitea's SSH forwarded, its HTTP(S) ports let in
d := MergeGateways(Compute("w15", nodes, nil, nil), ComputeGateways("w15", nodes, in), "w15", nodes)
if len(d.Forwards) != 1 || len(d.Accept) != 2 || len(d.Holders) != 0 {
t.Fatalf("holder: forwards %+v accept %+v holders %+v", d.Forwards, d.Accept, d.Holders)
}
r := Render(d, EgressDesired{}, "w15", &net.IPNet{IP: net.ParseIP("10.32.0.0"), Mask: net.CIDRMask(12, 32)}, nil)
for _, want := range []string{
"-A REWEAVE-LB-IN -d 86.107.168.15/32 -p tcp -m tcp --dport 443",
"-A REWEAVE-LB-IN -d 86.107.168.15/32 -p tcp -m tcp --dport 80",
} {
if !strings.Contains(r.Filter, want) {
t.Errorf("filter lacks %q:\n%s", want, r.Filter)
}
}
if !strings.Contains(r.Nat, "-A REWEAVE-LB -d 86.107.168.15/32 -p tcp -m tcp --dport 22") || !strings.Contains(r.Nat, "--to-destination 10.44.0.12:22") {
t.Errorf("nat lacks the SSH forward:\n%s", r.Nat)
}
// elsewhere: w15 is a holder whose replies come back to it
e := MergeGateways(Compute("cp", nodes, nil, nil), ComputeGateways("cp", nodes, in), "cp", nodes)
if len(e.Forwards) != 0 || len(e.Accept) != 0 || len(e.Holders) != 1 || e.Holders[0].Node.Name != "w15" {
t.Fatalf("cp: forwards %+v accept %+v holders %+v", e.Forwards, e.Accept, e.Holders)
}
}
func TestUnboundListenerReported(t *testing.T) {
in := giteaInput(t)
in.Unbound = map[string]string{"gateway-system/shared/https-gitea": "address already in use"}
d := ComputeGateways("w15", gwNodes(), in)
for _, l := range d.Gateways[0].Status.Listeners {
if l.Name != "https-gitea" {
continue
}
if a := condOf(l.Conditions, condAccepted); a.Status != metav1.ConditionFalse || a.Reason != "PortUnavailable" {
t.Fatalf("accepted: %+v", a)
}
if p := condOf(l.Conditions, condProgrammed); p.Status != metav1.ConditionFalse {
t.Fatalf("programmed: %+v", p)
}
}
served := false
for _, s := range d.Serve {
served = served || s.Name == "https-gitea"
}
if !served {
t.Fatal("an unbound listener is still tried on every pass")
}
}