Files
higress/pkg/ingress/kube/gateway/istio/conversion_test.go
2025-11-26 10:15:00 +08:00

1806 lines
42 KiB
Go

// Copyright Istio Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package istio
import (
"cmp"
"encoding/json"
"fmt"
"os"
"reflect"
"regexp"
"strings"
"sync"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
k8s "sigs.k8s.io/gateway-api/apis/v1"
"sigs.k8s.io/gateway-api/pkg/consts"
"sigs.k8s.io/yaml"
istio "istio.io/api/networking/v1alpha3"
"istio.io/istio/pilot/pkg/config/kube/crd"
"istio.io/istio/pilot/pkg/features"
"istio.io/istio/pilot/pkg/model"
"istio.io/istio/pilot/pkg/networking/core"
"istio.io/istio/pilot/pkg/serviceregistry/kube/controller"
"istio.io/istio/pilot/pkg/status"
"istio.io/istio/pilot/test/util"
"istio.io/istio/pkg/cluster"
"istio.io/istio/pkg/config"
"istio.io/istio/pkg/config/constants"
crdvalidation "istio.io/istio/pkg/config/crd"
"istio.io/istio/pkg/config/host"
"istio.io/istio/pkg/config/schema/gvk"
"istio.io/istio/pkg/config/schema/gvr"
"istio.io/istio/pkg/kube"
"istio.io/istio/pkg/kube/controllers"
"istio.io/istio/pkg/kube/kclient/clienttest"
"istio.io/istio/pkg/kube/krt"
"istio.io/istio/pkg/maps"
"istio.io/istio/pkg/ptr"
"istio.io/istio/pkg/slices"
"istio.io/istio/pkg/test"
"istio.io/istio/pkg/util/sets"
)
var ports = []*model.Port{
{
Name: "http",
Port: 80,
Protocol: "HTTP",
},
{
Name: "tcp",
Port: 34000,
Protocol: "TCP",
},
{
Name: "tcp-other",
Port: 34001,
Protocol: "TCP",
},
}
var services = []*model.Service{
{
Attributes: model.ServiceAttributes{
Name: "higress-gateway",
Namespace: "higress-system",
ClusterExternalAddresses: &model.AddressMap{
Addresses: map[cluster.ID][]string{
constants.DefaultClusterName: {"1.2.3.4"},
},
},
},
Ports: []*model.Port{
{
Name: "http",
Port: 80,
Protocol: "HTTP",
},
{
Name: "https",
Port: 443,
Protocol: "HTTPS",
},
{
Name: "tcp",
Port: 34000,
Protocol: "TCP",
},
{
Name: "tcp-other",
Port: 34001,
Protocol: "TCP",
},
},
Hostname: "higress-gateway.higress-system.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "higress-system",
},
Ports: ports,
Hostname: "example.com",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
Labels: map[string]string{
"higress.io/inferencepool-extension-service": "ext-proc-svc",
"higress.io/inferencepool-extension-port": "9002",
"higress.io/inferencepool-extension-failure-mode": "FailClose",
},
},
Ports: ports,
Hostname: host.Name(fmt.Sprintf("%s.default.svc.domain.suffix", func() string {
name, _ := InferencePoolServiceName("infpool-gen")
return name
}())),
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
Labels: map[string]string{
"higress.io/inferencepool-extension-service": "ext-proc-svc-2",
"higress.io/inferencepool-extension-port": "9002",
"higress.io/inferencepool-extension-failure-mode": "FailClose",
},
},
Ports: ports,
Hostname: host.Name(fmt.Sprintf("%s.default.svc.domain.suffix", func() string {
name, _ := InferencePoolServiceName("infpool-gen2")
return name
}())),
},
{
Attributes: model.ServiceAttributes{
Namespace: "apple",
},
Ports: ports,
Hostname: "httpbin-apple.apple.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "banana",
},
Ports: ports,
Hostname: "httpbin-banana.banana.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-second.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-wildcard.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "foo-svc.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-other.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "example.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "echo.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "cert",
},
Ports: ports,
Hostname: "httpbin.cert.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "service",
},
Ports: ports,
Hostname: "my-svc.service.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "google.com",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-1",
},
Ports: ports,
Hostname: "a-example.allowed-1.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-2",
},
Ports: ports,
Hostname: "a-example.allowed-2.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-1",
},
Ports: ports,
Hostname: "b-example.allowed-1.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-1",
},
Ports: ports,
Hostname: "svc2.allowed-1.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-2",
},
Ports: ports,
Hostname: "svc2.allowed-2.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-1",
},
Ports: ports,
Hostname: "svc1.allowed-1.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "allowed-2",
},
Ports: ports,
Hostname: "svc3.allowed-2.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "svc4.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "group-namespace1",
},
Ports: ports,
Hostname: "httpbin.group-namespace1.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "group-namespace2",
},
Ports: ports,
Hostname: "httpbin.group-namespace2.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-zero.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "higress-system",
},
Ports: ports,
Hostname: "httpbin.higress-system.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-mirror.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-foo.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-alt.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "higress-system",
},
Ports: ports,
Hostname: "istiod.higress-system.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "higress-system",
},
Ports: ports,
Hostname: "echo.higress-system.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
},
Ports: ports,
Hostname: "httpbin-bad.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Name: "echo-1",
Namespace: "default",
},
Ports: ports,
Hostname: "echo-1.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Name: "echo-2",
Namespace: "default",
},
Ports: ports,
Hostname: "echo-2.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Name: "echo-port",
Namespace: "default",
},
Ports: ports,
Hostname: "echo-port.default.svc.domain.suffix",
},
{
Attributes: model.ServiceAttributes{
Name: "not-found",
Namespace: "default",
},
Ports: ports,
Hostname: "not-found.default.svc.domain.suffix",
},
}
var svcPorts = []corev1.ServicePort{
{
Name: "http",
Port: 80,
Protocol: "HTTP",
},
{
Name: "https",
Port: 443,
Protocol: "HTTPS",
},
{
Name: "tcp",
Port: 34000,
Protocol: "TCP",
},
{
Name: "tcp-other",
Port: 34001,
Protocol: "TCP",
},
}
var (
// https://github.com/kubernetes/kubernetes/blob/v1.25.4/staging/src/k8s.io/kubectl/pkg/cmd/create/create_secret_tls_test.go#L31
rsaCertPEM = `-----BEGIN CERTIFICATE-----
MIIB0zCCAX2gAwIBAgIJAI/M7BYjwB+uMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTIwOTEyMjE1MjAyWhcNMTUwOTEyMjE1MjAyWjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANLJ
hPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wok/4xIA+ui35/MmNa
rtNuC+BdZ1tMuVCPFZcCAwEAAaNQME4wHQYDVR0OBBYEFJvKs8RfJaXTH08W+SGv
zQyKn0H8MB8GA1UdIwQYMBaAFJvKs8RfJaXTH08W+SGvzQyKn0H8MAwGA1UdEwQF
MAMBAf8wDQYJKoZIhvcNAQEFBQADQQBJlffJHybjDGxRMqaRmDhX0+6v02TUKZsW
r5QuVbpQhH6u+0UgcW0jp9QwpxoPTLTWGXEWBBBurxFwiCBhkQ+V
-----END CERTIFICATE-----
`
rsaKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBANLJhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wo
k/4xIA+ui35/MmNartNuC+BdZ1tMuVCPFZcCAwEAAQJAEJ2N+zsR0Xn8/Q6twa4G
6OB1M1WO+k+ztnX/1SvNeWu8D6GImtupLTYgjZcHufykj09jiHmjHx8u8ZZB/o1N
MQIhAPW+eyZo7ay3lMz1V01WVjNKK9QSn1MJlb06h/LuYv9FAiEA25WPedKgVyCW
SmUwbPw8fnTcpqDWE3yTO3vKcebqMSsCIBF3UmVue8YU3jybC3NxuXq3wNm34R8T
xVLHwDXh/6NJAiEAl2oHGGLz64BuAfjKrqwz7qMYr9HCLIe/YsoWq/olzScCIQDi
D2lWusoe2/nEqfDVVWGWlyJ7yOmqaVm/iNUN9B2N2g==
-----END RSA PRIVATE KEY-----
`
objects = []runtime.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cert-http",
Namespace: "higress-system",
},
Data: map[string][]byte{
"tls.crt": []byte(rsaCertPEM),
"tls.key": []byte(rsaKeyPEM),
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "ns2-cert",
Namespace: "ns2",
},
Data: map[string][]byte{
"tls.crt": []byte(rsaCertPEM),
"tls.key": []byte(rsaKeyPEM),
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "ns3-cert",
Namespace: "ns3",
},
Data: map[string][]byte{
"tls.crt": []byte(rsaCertPEM),
"tls.key": []byte(rsaKeyPEM),
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "ns4-cert",
Namespace: "ns4",
},
Data: map[string][]byte{
"tls.crt": []byte(rsaCertPEM),
"tls.key": []byte(rsaKeyPEM),
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cert-http2",
Namespace: "higress-system",
},
Data: map[string][]byte{
"tls.crt": []byte(rsaCertPEM),
"tls.key": []byte(rsaKeyPEM),
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "cert",
Namespace: "cert",
},
Data: map[string][]byte{
"tls.crt": []byte(rsaCertPEM),
"tls.key": []byte(rsaKeyPEM),
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "malformed",
Namespace: "higress-system",
},
Data: map[string][]byte{
// nolint: lll
// https://github.com/kubernetes-sigs/gateway-api/blob/d7f71d6b7df7e929ae299948973a693980afc183/conformance/tests/gateway-invalid-tls-certificateref.yaml#L87-L90
// this certificate is invalid because contains an invalid pem (base64 of "Hello world"),
// and the certificate and the key are identical
"tls.crt": []byte("SGVsbG8gd29ybGQK"),
"tls.key": []byte("SGVsbG8gd29ybGQK"),
},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "malformed",
Namespace: "higress-system",
},
Data: map[string]string{
"not-ca.crt": "hello",
},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "malformed-trustbundle",
Namespace: "higress-system",
},
Data: map[string]string{
"ca.crt": "hello",
},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cert-http",
Namespace: "higress-system",
},
Data: map[string]string{
"ca.crt": rsaCertPEM,
},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "malformed",
Namespace: "default",
},
Data: map[string]string{
"not-ca.crt": "hello",
},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-cert",
Namespace: "default",
},
Data: map[string]string{
"ca.crt": rsaCertPEM,
},
},
}
)
func init() {
features.EnableAlphaGatewayAPI = true
features.EnableAmbientWaypoints = true
features.EnableAmbientMultiNetwork = true
// Recompute with ambient enabled
classInfos = getClassInfos()
builtinClasses = getBuiltinClasses()
}
type TestStatusQueue struct {
mu sync.Mutex
state map[status.Resource]any
}
func (t *TestStatusQueue) EnqueueStatusUpdateResource(context any, target status.Resource) {
t.mu.Lock()
defer t.mu.Unlock()
t.state[target] = context
}
func (t *TestStatusQueue) Statuses() []any {
t.mu.Lock()
defer t.mu.Unlock()
return maps.Values(t.state)
}
func (t *TestStatusQueue) Dump() string {
t.mu.Lock()
defer t.mu.Unlock()
sb := strings.Builder{}
objs := []crd.IstioKind{}
for k, v := range t.state {
statusj, _ := json.Marshal(v)
gk, _ := gvk.FromGVR(k.GroupVersionResource)
obj := crd.IstioKind{
TypeMeta: metav1.TypeMeta{
Kind: gk.Kind,
APIVersion: k.GroupVersion().String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: k.Name,
Namespace: k.Namespace,
},
Spec: nil,
Status: ptr.Of(json.RawMessage(statusj)),
}
objs = append(objs, obj)
}
slices.SortFunc(objs, func(a, b crd.IstioKind) int {
ord := []string{gvk.GatewayClass.Kind, gvk.Gateway.Kind, gvk.HTTPRoute.Kind, gvk.GRPCRoute.Kind, gvk.TLSRoute.Kind, gvk.TCPRoute.Kind}
if r := cmp.Compare(slices.Index(ord, a.Kind), slices.Index(ord, b.Kind)); r != 0 {
return r
}
if r := a.CreationTimestamp.Time.Compare(b.CreationTimestamp.Time); r != 0 {
return r
}
if r := cmp.Compare(a.Namespace, b.Namespace); r != 0 {
return r
}
return cmp.Compare(a.Name, b.Name)
})
for _, obj := range objs {
b, err := yaml.Marshal(obj)
if err != nil {
panic(err.Error())
}
// Replace parts that are not stable
b = timestampRegex.ReplaceAll(b, []byte("lastTransitionTime: fake"))
sb.WriteString(string(b))
sb.WriteString("---\n")
}
return sb.String()
}
var _ status.Queue = &TestStatusQueue{}
func TestConvertResources(t *testing.T) {
validator := crdvalidation.NewIstioValidator(t)
cases := []struct {
name string
// Some configs are intended to be generated with invalid configs, and since they will be validated
// by the validator, we need to ignore the validation errors to prevent the test from failing.
validationIgnorer *crdvalidation.ValidationIgnorer
}{
{name: "http"},
{name: "tcp"},
{name: "tls"},
{name: "grpc"},
{name: "mismatch"},
{name: "weighted"},
{name: "zero"},
//{name: "mesh"},
{
name: "invalid",
validationIgnorer: crdvalidation.NewValidationIgnorer(
"default/^invalid-backendRef-kind-",
"default/^invalid-backendRef-mixed-",
),
},
//{name: "multi-gateway"},
{name: "delegated"},
{name: "route-binding"},
{name: "reference-policy-tls"},
{
name: "reference-policy-service",
validationIgnorer: crdvalidation.NewValidationIgnorer(
"higress-system/^backend-not-allowed-",
),
},
{
name: "reference-policy-tcp",
validationIgnorer: crdvalidation.NewValidationIgnorer(
"higress-system/^not-allowed-echo-",
),
},
{
name: "reference-policy-inferencepool",
validationIgnorer: crdvalidation.NewValidationIgnorer(
"higress-system/^backend-not-allowed-",
),
},
//{name: "serviceentry"},
//{name: "status"},
//{name: "eastwest"},
//{name: "eastwest-tlsoption"},
//{name: "eastwest-labelport"},
//{name: "eastwest-remote"},
//{name: "east-west-ambient"},
//{name: "mcs"},
//{name: "route-precedence"},
//{name: "waypoint"},
//{name: "isolation"},
{name: "backend-lb-policy"},
{
name: "backend-tls-policy",
validationIgnorer: crdvalidation.NewValidationIgnorer(
"default/echo-https",
"default/external-service",
"default/multi-host-service",
),
},
{name: "mix-backend-policy"},
//{name: "listenerset"},
//{name: "listenerset-cross-namespace"},
//{name: "listenerset-invalid"},
//{
// name: "listenerset-empty-listeners",
// validationIgnorer: crdvalidation.NewValidationIgnorer(
// "higress-system/parent-gateway",
// ),
//},
//{
// name: "valid-invalid-parent-ref",
// validationIgnorer: crdvalidation.NewValidationIgnorer(
// "default/^valid-invalid-parent-ref-",
// ),
//},
}
test.SetForTest(t, &features.EnableGatewayAPIGatewayClassController, false)
test.SetForTest(t, &features.EnableGatewayAPIInferenceExtension, true)
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
stop := test.NewStop(t)
input := readConfig(t, fmt.Sprintf("testdata/%s.yaml", tt.name), validator, tt.validationIgnorer)
kc := kube.NewFakeClient(input...)
setupClientCRDs(t, kc)
// Setup a few preconfigured services
instances := []*model.ServiceInstance{}
for _, svc := range services {
for i, port := range svc.Ports {
epPort := uint32(0)
if i == 0 {
epPort = 8080 // Just to make sure we test mismatch
}
instances = append(instances, &model.ServiceInstance{
Service: svc,
ServicePort: port,
Endpoint: &model.IstioEndpoint{EndpointPort: epPort},
})
}
}
cg := core.NewConfigGenTest(t, core.TestOptions{
Services: services,
Instances: instances,
})
dbg := &krt.DebugHandler{}
dumpOnFailure(t, dbg)
ctrl := NewController(
kc,
AlwaysReady,
controller.Options{DomainSuffix: "domain.suffix", KrtDebugger: dbg},
nil,
)
sq := &TestStatusQueue{
state: map[status.Resource]any{},
}
go ctrl.Run(stop)
kc.RunAndWait(stop)
ctrl.Reconcile(cg.PushContext())
kube.WaitForCacheSync("test", stop, ctrl.HasSynced)
// Normally we don't care to block on status being written, but here we need to since we want to test output
statusSynced := ctrl.status.SetQueue(sq)
for _, st := range statusSynced {
st.WaitUntilSynced(stop)
}
res := ctrl.List(gvk.Gateway, "")
sortConfigByCreationTime(res)
vs := ctrl.List(gvk.VirtualService, "")
res = append(res, sortedConfigByCreationTime(vs)...)
dr := ctrl.List(gvk.DestinationRule, "")
res = append(res, sortedConfigByCreationTime(dr)...)
goldenFile := fmt.Sprintf("testdata/%s.yaml.golden", tt.name)
b := marshalYaml(t, res)
//t.Logf("marshaled yaml result : %s", string(b))
util.CompareContent(t, b, goldenFile)
outputStatus := sq.Dump()
goldenStatusFile := fmt.Sprintf("testdata/%s.status.yaml.golden", tt.name)
util.CompareContent(t, []byte(outputStatus), goldenStatusFile)
})
}
}
func setupClientCRDs(t *testing.T, kc kube.CLIClient) {
for _, crd := range []schema.GroupVersionResource{
gvr.KubernetesGateway,
gvr.ReferenceGrant,
gvr.XListenerSet,
gvr.GatewayClass,
gvr.HTTPRoute,
gvr.GRPCRoute,
gvr.TCPRoute,
gvr.TLSRoute,
gvr.ServiceEntry,
gvr.XBackendTrafficPolicy,
gvr.BackendTLSPolicy,
gvr.InferencePool,
} {
clienttest.MakeCRDWithAnnotations(t, kc, crd, map[string]string{
consts.BundleVersionAnnotation: "v1.1.0",
})
}
}
func dumpOnFailure(t *testing.T, debugger *krt.DebugHandler) {
t.Cleanup(func() {
if t.Failed() {
b, _ := yaml.Marshal(debugger)
t.Log(string(b))
}
})
}
func TestSortHTTPRoutes(t *testing.T) {
cases := []struct {
name string
in []*istio.HTTPRoute
out []*istio.HTTPRoute
}{
{
"match is preferred over no match",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{
Exact: "/foo",
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{
Exact: "/foo",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{},
},
},
},
{
"path matching exact > regex > prefix",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Regex{
Regex: ".*foo",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{
Exact: "/foo",
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{
Exact: "/foo",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Regex{
Regex: ".*foo",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/",
},
},
},
},
},
},
},
{
"path prefix matching with largest characters",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/foo",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/foobar",
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/foobar",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/foo",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/",
},
},
},
},
},
},
},
{
"path match is preferred over method match",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Method: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{
Exact: "GET",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/foobar",
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/foobar",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Method: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{
Exact: "GET",
},
},
},
},
},
},
},
{
"largest number of header matches is preferred",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Headers: map[string]*istio.StringMatch{
"header1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Headers: map[string]*istio.StringMatch{
"header1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
"header2": {
MatchType: &istio.StringMatch_Exact{
Exact: "value2",
},
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Headers: map[string]*istio.StringMatch{
"header1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
"header2": {
MatchType: &istio.StringMatch_Exact{
Exact: "value2",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Headers: map[string]*istio.StringMatch{
"header1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
},
},
{
"largest number of query params is preferred",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
QueryParams: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
QueryParams: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
"param2": {
MatchType: &istio.StringMatch_Exact{
Exact: "value2",
},
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
QueryParams: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
"param2": {
MatchType: &istio.StringMatch_Exact{
Exact: "value2",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
QueryParams: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
},
},
{
"method > header > query params > path",
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/",
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
QueryParams: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Method: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{Exact: "GET"},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Headers: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
},
[]*istio.HTTPRoute{
{
Match: []*istio.HTTPMatchRequest{
{
Method: &istio.StringMatch{
MatchType: &istio.StringMatch_Exact{Exact: "GET"},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Headers: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
QueryParams: map[string]*istio.StringMatch{
"param1": {
MatchType: &istio.StringMatch_Exact{
Exact: "value1",
},
},
},
},
},
},
{
Match: []*istio.HTTPMatchRequest{
{
Uri: &istio.StringMatch{
MatchType: &istio.StringMatch_Prefix{
Prefix: "/",
},
},
},
},
},
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
sortHTTPRoutes(tt.in)
if !reflect.DeepEqual(tt.in, tt.out) {
t.Fatalf("expected %v, got %v", tt.out, tt.in)
}
})
}
}
// Test is a little janky, but it checks if we can pass a `parent.Hostnames` in the form
// of `*.example.com` and `*/*.example.com` without a panic and successfully match.
func TestGatewayReferenceAllowedParentHostnameParsing(t *testing.T) {
cases := []struct {
Name string
ParentHostnames []string
RouteHostnames []k8s.Hostname
}{
{
Name: "implied wildcard",
ParentHostnames: []string{"*.example.com"},
RouteHostnames: []k8s.Hostname{"bookinfo.example.com"},
},
{
Name: "explicit wildcard",
ParentHostnames: []string{"*/*.example.com"},
RouteHostnames: []k8s.Hostname{"bookinfo.example.com"},
},
}
for _, tt := range cases {
t.Run(tt.Name, func(t *testing.T) {
// ctx doesn't end up getting used, but we need to pass something
ctx := RouteContext{}
routeKind := gvk.HTTPRoute
parent := parentInfo{
InternalName: "default/bookinfo-gateway-istio-autogenerated-k8s-gateway-http",
Hostnames: []string{"*.example.com"},
AllowedKinds: []k8s.RouteGroupKind{
toRouteKind(gvk.HTTPRoute),
toRouteKind(gvk.GRPCRoute),
},
OriginalHostname: "",
SectionName: "http",
Port: 80,
Protocol: "HTTP",
}
parentRef := parentReference{
parentKey: parentKey{
Kind: gvk.Gateway,
Name: "bookinfo-gateway",
Namespace: "default",
},
SectionName: "",
Port: 0,
}
hostnames := []k8s.Hostname{"bookinfo.example.com"}
parentError, waypointError := referenceAllowed(ctx, &parent, routeKind, parentRef, hostnames, "default")
if parentError != nil {
t.Fatalf("expected no error, got %v", parentError)
}
if waypointError != nil {
t.Fatalf("expected no error, got %v", waypointError)
}
})
}
}
func TestReferencePolicy(t *testing.T) {
validator := crdvalidation.NewIstioValidator(t)
type res struct {
name, namespace string
allowed bool
}
cases := []struct {
name string
config string
expectations []res
}{
{
name: "simple",
config: `apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateways-to-ref-secrets
namespace: default
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: higress-system
to:
- group: ""
kind: Secret
`,
expectations: []res{
// allow cross namespace
{"kubernetes-gateway://default/wildcard-example-com-cert", "higress-system", true},
// denied same namespace. We do not implicitly allow (in this code - higher level code does)
{"kubernetes-gateway://default/wildcard-example-com-cert", "default", false},
// denied namespace
{"kubernetes-gateway://default/wildcard-example-com-cert", "bad", false},
},
},
{
name: "multiple in one",
config: `apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateways-to-ref-secrets
namespace: default
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: ns-1
- group: gateway.networking.k8s.io
kind: Gateway
namespace: ns-2
to:
- group: ""
kind: Secret
`,
expectations: []res{
{"kubernetes-gateway://default/wildcard-example-com-cert", "ns-1", true},
{"kubernetes-gateway://default/wildcard-example-com-cert", "ns-2", true},
{"kubernetes-gateway://default/wildcard-example-com-cert", "bad", false},
},
},
{
name: "multiple",
config: `apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: ns1
namespace: default
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: ns-1
to:
- group: ""
kind: Secret
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: ns2
namespace: default
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: ns-2
to:
- group: ""
kind: Secret
`,
expectations: []res{
{"kubernetes-gateway://default/wildcard-example-com-cert", "ns-1", true},
{"kubernetes-gateway://default/wildcard-example-com-cert", "ns-2", true},
{"kubernetes-gateway://default/wildcard-example-com-cert", "bad", false},
},
},
{
name: "same namespace",
config: `apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateways-to-ref-secrets
namespace: default
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: default
to:
- group: ""
kind: Secret
`,
expectations: []res{
{"kubernetes-gateway://default/wildcard-example-com-cert", "higress-system", false},
{"kubernetes-gateway://default/wildcard-example-com-cert", "default", true},
{"kubernetes-gateway://default/wildcard-example-com-cert", "bad", false},
},
},
{
name: "same name",
config: `apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateways-to-ref-secrets
namespace: default
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: default
to:
- group: ""
kind: Secret
name: public
`,
expectations: []res{
{"kubernetes-gateway://default/public", "higress-system", false},
{"kubernetes-gateway://default/public", "default", true},
{"kubernetes-gateway://default/private", "default", false},
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
input := readConfigString(t, tt.config, validator, nil)
kr := setupController(t, input...)
for _, sc := range tt.expectations {
t.Run(fmt.Sprintf("%v/%v", sc.name, sc.namespace), func(t *testing.T) {
got := kr.SecretAllowed(gvk.KubernetesGateway, sc.name, sc.namespace)
if got != sc.allowed {
t.Fatalf("expected allowed=%v, got allowed=%v", sc.allowed, got)
}
})
}
})
}
}
var timestampRegex = regexp.MustCompile(`lastTransitionTime:.*`)
func readConfig(t testing.TB, filename string, validator *crdvalidation.Validator, ignorer *crdvalidation.ValidationIgnorer) []runtime.Object {
t.Helper()
data, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("failed to read input yaml file: %v", err)
}
objs := readConfigString(t, string(data), validator, ignorer)
namespaces := sets.New[string](slices.Map(objs, func(e runtime.Object) string {
return e.(controllers.Object).GetNamespace()
})...)
for _, svc := range services {
if !strings.HasSuffix(svc.Hostname.String(), "domain.suffix") {
continue
}
name := svc.Attributes.Name
if name == "" {
name, _, _ = strings.Cut(svc.Hostname.String(), ".")
}
svcObj := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Namespace: svc.Attributes.Namespace,
Name: name,
Labels: svc.Attributes.Labels,
},
Spec: corev1.ServiceSpec{
Ports: svcPorts,
},
}
objs = append(objs, svcObj)
}
objs = append(objs, objects...)
for ns := range namespaces {
objs = append(objs, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: ns,
Labels: map[string]string{
"higress.io/test-name-part": strings.Split(ns, "-")[0],
},
},
})
}
return objs
}
func readConfigString(t testing.TB, data string, validator *crdvalidation.Validator, ignorer *crdvalidation.ValidationIgnorer,
) []runtime.Object {
if err := validator.ValidateCustomResourceYAML(data, ignorer); err != nil {
t.Error(err)
}
c, err := kubernetesObjectsFromString(data)
if err != nil {
t.Fatalf("failed to parse CRD: %v", err)
}
return c
}
// Print as YAML
func marshalYaml(t test.Failer, cl []config.Config) []byte {
t.Helper()
result := []byte{}
separator := []byte("---\n")
for _, config := range cl {
obj, err := crd.ConvertConfig(config)
if err != nil {
t.Fatalf("Could not decode %v: %v", config.Name, err)
}
bytes, err := yaml.Marshal(obj)
if err != nil {
t.Fatalf("Could not convert %v to YAML: %v", config, err)
}
result = append(result, bytes...)
result = append(result, separator...)
}
return result
}
func TestHumanReadableJoin(t *testing.T) {
tests := []struct {
input []string
want string
}{
{[]string{"a"}, "a"},
{[]string{"a", "b"}, "a and b"},
{[]string{"a", "b", "c"}, "a, b, and c"},
}
for _, tt := range tests {
t.Run(strings.Join(tt.input, "_"), func(t *testing.T) {
if got := humanReadableJoin(tt.input); !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
//
//func BenchmarkBuildHTTPVirtualServices(b *testing.B) {
// ports := []*model.Port{
// {
// Name: "http",
// Port: 80,
// Protocol: "HTTP",
// },
// {
// Name: "tcp",
// Port: 34000,
// Protocol: "TCP",
// },
// }
// ingressSvc := &model.Service{
// Attributes: model.ServiceAttributes{
// Name: "istio-ingressgateway",
// Namespace: "higress-system",
// ClusterExternalAddresses: &model.AddressMap{
// Addresses: map[cluster.ID][]string{
// constants.DefaultClusterName: {"1.2.3.4"},
// },
// },
// },
// Ports: ports,
// Hostname: "istio-ingressgateway.higress-system.svc.domain.suffix",
// }
// altIngressSvc := &model.Service{
// Attributes: model.ServiceAttributes{
// Namespace: "higress-system",
// },
// Ports: ports,
// Hostname: "example.com",
// }
// cg := core.NewConfigGenTest(b, core.TestOptions{
// Services: []*model.Service{ingressSvc, altIngressSvc},
// Instances: []*model.ServiceInstance{
// {Service: ingressSvc, ServicePort: ingressSvc.Ports[0], Endpoint: &model.IstioEndpoint{EndpointPort: 8080}},
// {Service: ingressSvc, ServicePort: ingressSvc.Ports[1], Endpoint: &model.IstioEndpoint{}},
// {Service: altIngressSvc, ServicePort: altIngressSvc.Ports[0], Endpoint: &model.IstioEndpoint{}},
// {Service: altIngressSvc, ServicePort: altIngressSvc.Ports[1], Endpoint: &model.IstioEndpoint{}},
// },
// })
//
// validator := crdvalidation.NewIstioValidator(b)
// input := readConfig(b, "testdata/benchmark-httproute.yaml", validator, nil)
// kr := splitInput(b, input)
// kr.Context = NewGatewayContext(cg.PushContext(), "Kubernetes")
// ctx := configContext{
// GatewayResources: kr,
// AllowedReferences: convertReferencePolicies(kr),
// }
// _, gwMap, _ := convertGateways(ctx)
// ctx.GatewayReferences = gwMap
//
// b.ResetTimer()
// for n := 0; n < b.N; n++ {
// // for gateway routes, build one VS per gateway+host
// gatewayRoutes := make(map[string]map[string]*config.Config)
// // for mesh routes, build one VS per namespace+host
// meshRoutes := make(map[string]map[string]*config.Config)
// for _, obj := range kr.HTTPRoute {
// buildHTTPVirtualServices(ctx, obj, gatewayRoutes, meshRoutes)
// }
// }
//}
//func TestExtractGatewayServices(t *testing.T) {
// tests := []struct {
// name string
// r GatewayResources
// kgw *k8s.Gateway
// obj config.Config
// gatewayServices []string
// err *ConfigError
// }{
// {
// name: "managed gateway",
// r: GatewayResources{Domain: "cluster.local"},
// kgw: &k8s.GatewaySpec{
// GatewayClassName: "istio",
// },
// obj: config.Config{
// Meta: config.Meta{
// Name: "foo",
// Namespace: "default",
// },
// },
// gatewayServices: []string{"foo-istio.default.svc.cluster.local"},
// },
// {
// name: "managed gateway with name overridden",
// r: GatewayResources{Domain: "cluster.local"},
// kgw: &k8s.GatewaySpec{
// GatewayClassName: "istio",
// },
// obj: config.Config{
// Meta: config.Meta{
// Name: "foo",
// Namespace: "default",
// Annotations: map[string]string{
// annotation.GatewayNameOverride.Name: "bar",
// },
// },
// },
// gatewayServices: []string{"bar.default.svc.cluster.local"},
// },
// {
// name: "unmanaged gateway",
// r: GatewayResources{Domain: "domain"},
// kgw: &k8s.GatewaySpec{
// GatewayClassName: "istio",
// Addresses: []k8s.GatewayAddress{
// {
// Value: "abc",
// },
// {
// Type: func() *k8s.AddressType {
// t := k8s.HostnameAddressType
// return &t
// }(),
// Value: "example.com",
// },
// {
// Type: func() *k8s.AddressType {
// t := k8s.IPAddressType
// return &t
// }(),
// Value: "1.2.3.4",
// },
// },
// },
// obj: config.Config{
// Meta: config.Meta{
// Name: "foo",
// Namespace: "default",
// },
// },
// gatewayServices: []string{"abc.default.svc.domain", "example.com"},
// err: &ConfigError{
// Reason: InvalidAddress,
// Message: "only Hostname is supported, ignoring [1.2.3.4]",
// },
// },
// }
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// gatewayServices, err := extractGatewayServices(tt.r, tt.kgw, tt.obj, classInfo{})
// assert.Equal(t, gatewayServices, tt.gatewayServices)
// assert.Equal(t, err, tt.err)
// })
// }
//}
func kubernetesObjectsFromString(s string) ([]runtime.Object, error) {
var objects []runtime.Object
decode := kube.IstioCodec.UniversalDeserializer().Decode
objectStrs := strings.Split(s, "---")
for _, s := range objectStrs {
if len(strings.TrimSpace(s)) == 0 {
continue
}
o, _, err := decode([]byte(s), nil, nil)
if err != nil {
return nil, fmt.Errorf("failed deserializing kubernetes object: %v (%v)", err, s)
}
objects = append(objects, o)
}
return objects, nil
}
func firstValue[T, U any](val T, _ U) T {
return val
}