diff --git a/pkg/apis/networking/register.go b/pkg/apis/networking/register.go index 676f5684b..6d5270f54 100644 --- a/pkg/apis/networking/register.go +++ b/pkg/apis/networking/register.go @@ -42,7 +42,7 @@ const ( RolloutAnnotationKey = GroupName + "/rollout" // TagToHostAnnotationKey is the annotation key used for storing a JSON map of - // tags to host names + // tag names to hostnames that should be routed to that tag. TagToHostAnnotationKey = GroupName + "/tag-to-host" ) diff --git a/pkg/ingress/tags.go b/pkg/ingress/tags.go new file mode 100644 index 000000000..c4dc08430 --- /dev/null +++ b/pkg/ingress/tags.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 The Knative 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 ingress + +import ( + "encoding/json" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" + apisnet "knative.dev/networking/pkg/apis/networking" + "knative.dev/networking/pkg/apis/networking/v1alpha1" + "knative.dev/networking/pkg/http/header" + "knative.dev/pkg/network" +) + +// TagToHosts parses the tag-to-host annotation into sets keyed by tag. +// Invalid annotations and empty host lists are ignored. +func TagToHosts(ing *v1alpha1.Ingress) map[string]sets.Set[string] { + serialized := ing.GetAnnotations()[apisnet.TagToHostAnnotationKey] + if serialized == "" { + return nil + } + + parsed := make(map[string][]string) + if err := json.Unmarshal([]byte(serialized), &parsed); err != nil { + return nil + } + + tagToHosts := make(map[string]sets.Set[string], len(parsed)) + for tag, hosts := range parsed { + if len(hosts) == 0 { + continue + } + tagToHosts[tag] = sets.New(hosts...) + } + return tagToHosts +} + +// HostsForTag returns the hostnames for a tag filtered by ingress visibility. +func HostsForTag(tag string, visibility v1alpha1.IngressVisibility, tagToHosts map[string]sets.Set[string]) sets.Set[string] { + if len(tagToHosts) == 0 { + return sets.New[string]() + } + hosts, ok := tagToHosts[tag] + if !ok { + return sets.New[string]() + } + + switch visibility { + case v1alpha1.IngressVisibilityClusterLocal: + return ExpandedHosts(filterLocalHostnames(hosts)) + default: + return ExpandedHosts(filterNonLocalHostnames(hosts)) + } +} + +// MakeTagHostIngressPath clones a header-based tag path into a host-based one. +func MakeTagHostIngressPath(path *v1alpha1.HTTPIngressPath, tag string) *v1alpha1.HTTPIngressPath { + tagPath := path.DeepCopy() + if tagPath.Headers != nil { + delete(tagPath.Headers, header.RouteTagKey) + if len(tagPath.Headers) == 0 { + tagPath.Headers = nil + } + } + if tagPath.AppendHeaders == nil { + tagPath.AppendHeaders = make(map[string]string, 1) + } + tagPath.AppendHeaders[header.RouteTagKey] = tag + return tagPath +} + +// RouteTagHeaderValue returns the value of the route tag header match. +func RouteTagHeaderValue(headers map[string]v1alpha1.HeaderMatch) string { + if len(headers) == 0 { + return "" + } + match, ok := headers[header.RouteTagKey] + if !ok { + return "" + } + return match.Exact +} + +// RouteTagAppendValue returns the value of the route tag append header. +func RouteTagAppendValue(headers map[string]string) string { + if len(headers) == 0 { + return "" + } + return headers[header.RouteTagKey] +} + +// RouteHosts returns the hostnames addressed by a path, including synthesized +// tag hosts for append-header-only tag routes. +func RouteHosts(ruleHosts sets.Set[string], path *v1alpha1.HTTPIngressPath, visibility v1alpha1.IngressVisibility, tagToHosts map[string]sets.Set[string]) sets.Set[string] { + hosts := ruleHosts + if tag := RouteTagAppendValue(path.AppendHeaders); tag != "" && RouteTagHeaderValue(path.Headers) == "" { + hosts = hosts.Union(HostsForTag(tag, visibility, tagToHosts)) + } + return hosts +} + +// HostRouteTags returns the set of tags already represented as host-based paths. +func HostRouteTags(rule *v1alpha1.IngressRule) sets.Set[string] { + tags := sets.New[string]() + if rule.HTTP == nil { + return tags + } + for _, path := range rule.HTTP.Paths { + tag := RouteTagAppendValue(path.AppendHeaders) + if tag == "" || RouteTagHeaderValue(path.Headers) != "" { + continue + } + tags.Insert(tag) + } + return tags +} + +func filterLocalHostnames(hosts sets.Set[string]) sets.Set[string] { + localSvcSuffix := ".svc." + network.GetClusterDomainName() + retained := sets.New[string]() + for _, host := range sets.List(hosts) { + if strings.HasSuffix(host, localSvcSuffix) { + retained.Insert(host) + } + } + return retained +} + +func filterNonLocalHostnames(hosts sets.Set[string]) sets.Set[string] { + localSvcSuffix := ".svc." + network.GetClusterDomainName() + retained := sets.New[string]() + for _, host := range sets.List(hosts) { + if !strings.HasSuffix(host, localSvcSuffix) { + retained.Insert(host) + } + } + return retained +} diff --git a/pkg/ingress/tags_test.go b/pkg/ingress/tags_test.go new file mode 100644 index 000000000..898c5085b --- /dev/null +++ b/pkg/ingress/tags_test.go @@ -0,0 +1,203 @@ +/* +Copyright 2026 The Knative 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 ingress + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + apisnet "knative.dev/networking/pkg/apis/networking" + "knative.dev/networking/pkg/apis/networking/v1alpha1" + "knative.dev/networking/pkg/http/header" +) + +func TestTagToHosts(t *testing.T) { + tests := []struct { + name string + ing *v1alpha1.Ingress + want map[string]sets.Set[string] + }{{ + name: "missing annotation", + ing: &v1alpha1.Ingress{}, + }, { + name: "invalid annotation", + ing: &v1alpha1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + apisnet.TagToHostAnnotationKey: "{", + }}, + }, + }, { + name: "valid annotation", + ing: &v1alpha1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + apisnet.TagToHostAnnotationKey: `{"blue":["blue.example.com","blue.example.com"],"green":[],"internal":["green.test-ns.svc.cluster.local"]}`, + }}, + }, + want: map[string]sets.Set[string]{ + "blue": sets.New("blue.example.com"), + "internal": sets.New("green.test-ns.svc.cluster.local"), + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if diff := cmp.Diff(asSortedSlices(test.want), asSortedSlices(TagToHosts(test.ing))); diff != "" { + t.Fatalf("TagToHosts diff (-want,+got):\n%s", diff) + } + }) + } +} + +func TestHostsForTag(t *testing.T) { + tagToHosts := map[string]sets.Set[string]{ + "blue": sets.New( + "blue.example.com", + "blue.test-ns.svc.cluster.local", + ), + } + + if diff := cmp.Diff( + sets.List(sets.New("blue.example.com")), + sets.List(HostsForTag("blue", v1alpha1.IngressVisibilityExternalIP, tagToHosts)), + ); diff != "" { + t.Fatalf("external HostsForTag diff (-want,+got):\n%s", diff) + } + + if diff := cmp.Diff( + sets.List(sets.New( + "blue.test-ns", + "blue.test-ns.svc", + "blue.test-ns.svc.cluster.local", + )), + sets.List(HostsForTag("blue", v1alpha1.IngressVisibilityClusterLocal, tagToHosts)), + ); diff != "" { + t.Fatalf("cluster-local HostsForTag diff (-want,+got):\n%s", diff) + } +} + +func TestMakeTagHostIngressPath(t *testing.T) { + original := &v1alpha1.HTTPIngressPath{ + Headers: map[string]v1alpha1.HeaderMatch{ + header.RouteTagKey: {Exact: "blue"}, + "X-Test": {Exact: "preserved"}, + }, + AppendHeaders: map[string]string{ + "X-Existing": "value", + }, + } + + got := MakeTagHostIngressPath(original, "blue") + + if diff := cmp.Diff( + map[string]string{ + "X-Test": headerMatchValue(got.Headers["X-Test"]), + }, + map[string]string{ + "X-Test": "preserved", + }, + ); diff != "" { + t.Fatalf("MakeTagHostIngressPath headers diff (-want,+got):\n%s", diff) + } + + if got.AppendHeaders[header.RouteTagKey] != "blue" { + t.Fatalf("MakeTagHostIngressPath append tag = %q, want %q", got.AppendHeaders[header.RouteTagKey], "blue") + } + if _, ok := original.AppendHeaders[header.RouteTagKey]; ok { + t.Fatal("MakeTagHostIngressPath mutated original append headers") + } + if _, ok := got.Headers[header.RouteTagKey]; ok { + t.Fatal("MakeTagHostIngressPath kept route tag header match") + } +} + +func TestRouteHosts(t *testing.T) { + ruleHosts := sets.New("route.example.com") + tagToHosts := map[string]sets.Set[string]{ + "blue": sets.New("blue.example.com"), + } + + hostPath := &v1alpha1.HTTPIngressPath{ + AppendHeaders: map[string]string{ + header.RouteTagKey: "blue", + }, + } + if diff := cmp.Diff( + sets.List(sets.New("blue.example.com", "route.example.com")), + sets.List(RouteHosts(ruleHosts, hostPath, v1alpha1.IngressVisibilityExternalIP, tagToHosts)), + ); diff != "" { + t.Fatalf("host RouteHosts diff (-want,+got):\n%s", diff) + } + + headerPath := &v1alpha1.HTTPIngressPath{ + Headers: map[string]v1alpha1.HeaderMatch{ + header.RouteTagKey: {Exact: "blue"}, + }, + AppendHeaders: map[string]string{ + header.RouteTagKey: "blue", + }, + } + if diff := cmp.Diff( + sets.List(ruleHosts), + sets.List(RouteHosts(ruleHosts, headerPath, v1alpha1.IngressVisibilityExternalIP, tagToHosts)), + ); diff != "" { + t.Fatalf("header RouteHosts diff (-want,+got):\n%s", diff) + } +} + +func TestHostRouteTags(t *testing.T) { + rule := &v1alpha1.IngressRule{ + HTTP: &v1alpha1.HTTPIngressRuleValue{ + Paths: []v1alpha1.HTTPIngressPath{{ + AppendHeaders: map[string]string{ + header.RouteTagKey: "blue", + }, + }, { + Headers: map[string]v1alpha1.HeaderMatch{ + header.RouteTagKey: {Exact: "green"}, + }, + }, { + Headers: map[string]v1alpha1.HeaderMatch{ + header.RouteTagKey: {Exact: "red"}, + }, + AppendHeaders: map[string]string{ + header.RouteTagKey: "red", + }, + }}, + }, + } + + if diff := cmp.Diff(sets.List(sets.New("blue")), sets.List(HostRouteTags(rule))); diff != "" { + t.Fatalf("HostRouteTags diff (-want,+got):\n%s", diff) + } +} + +func asSortedSlices(in map[string]sets.Set[string]) map[string][]string { + if len(in) == 0 { + return nil + } + out := make(map[string][]string, len(in)) + for tag, hosts := range in { + out[tag] = sets.List(hosts) + } + return out +} + +func headerMatchValue(match v1alpha1.HeaderMatch) string { + return match.Exact +} diff --git a/test/conformance/ingress/headers.go b/test/conformance/ingress/headers.go index 762e93442..9eb8479c2 100644 --- a/test/conformance/ingress/headers.go +++ b/test/conformance/ingress/headers.go @@ -210,6 +210,95 @@ func TestTagHeaders(t *testing.T) { } } +// TestTagToHost verifies that an Ingress properly dispatches to backends based on +// the requested host when a tag-to-host annotation is present. +func TestTagToHost(t *testing.T) { + t.Parallel() + ctx, clients := context.Background(), test.Setup(t) + + name, port, _ := CreateRuntimeService(ctx, t, clients, networking.ServicePortNameHTTP1) + + const ( + tagName = "the-tag" + backendHeader = "Which-Backend" + backendWithTag = "tag" + backendWithoutTag = "no-tag" + ) + + defaultHost := name + "." + test.NetworkingFlags.ServiceDomain + tagHost := "tag-" + name + "." + test.NetworkingFlags.ServiceDomain + + _, client, _ := CreateIngressReadyWithOptions(ctx, t, clients, v1alpha1.IngressSpec{ + Rules: []v1alpha1.IngressRule{{ + Hosts: []string{defaultHost}, + Visibility: v1alpha1.IngressVisibilityExternalIP, + HTTP: &v1alpha1.HTTPIngressRuleValue{ + Paths: []v1alpha1.HTTPIngressPath{{ + Headers: map[string]v1alpha1.HeaderMatch{ + header.RouteTagKey: { + Exact: tagName, + }, + }, + AppendHeaders: map[string]string{ + backendHeader: backendWithTag, + }, + Splits: []v1alpha1.IngressBackendSplit{{ + IngressBackend: v1alpha1.IngressBackend{ + ServiceName: name, + ServiceNamespace: test.ServingNamespace, + ServicePort: intstr.FromInt(port), + }, + }}, + }, { + AppendHeaders: map[string]string{ + backendHeader: backendWithoutTag, + }, + Splits: []v1alpha1.IngressBackendSplit{{ + IngressBackend: v1alpha1.IngressBackend{ + ServiceName: name, + ServiceNamespace: test.ServingNamespace, + ServicePort: intstr.FromInt(port), + }, + }}, + }}, + }, + }}, + }, OverrideIngressAnnotation(map[string]string{ + networking.IngressClassAnnotationKey: test.NetworkingFlags.IngressClass, + networking.TagToHostAnnotationKey: fmt.Sprintf(`{"%s":["%s"]}`, tagName, tagHost), + })) + + tests := []struct { + name string + host string + want string + }{{ + name: "tag host routes to tagged backend", + host: tagHost, + want: backendWithTag, + }, { + name: "default host routes to untagged backend", + host: defaultHost, + want: backendWithoutTag, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ri := RuntimeRequest(ctx, t, client, "http://"+tt.host) + if ri == nil { + t.Error("Couldn't make request") + return + } + + if got := ri.Request.Headers.Get(backendHeader); got != tt.want { + t.Errorf("Header[%q] = %q, wanted %q", backendHeader, got, tt.want) + } + }) + } +} + // TestPreSplitSetHeaders verifies that an Ingress that specified AppendHeaders pre-split has the appropriate header(s) set. func TestPreSplitSetHeaders(t *testing.T) { t.Parallel() diff --git a/test/conformance/ingress/run.go b/test/conformance/ingress/run.go index d914521d0..3eeedfd8b 100644 --- a/test/conformance/ingress/run.go +++ b/test/conformance/ingress/run.go @@ -50,8 +50,9 @@ var stableTests = map[string]func(t *testing.T){ var betaTests = map[string]func(t *testing.T){ // Add your conformance test for beta features - "host-rewrite": TestRewriteHost, - "headers/tags": TestTagHeaders, + "host-rewrite": TestRewriteHost, + "headers/tags": TestTagHeaders, + "headers/tag-to-host": TestTagToHost, } var alphaTests = map[string]func(t *testing.T){ diff --git a/test/conformance/ingress/util.go b/test/conformance/ingress/util.go index 0e5889e25..725aae9d8 100644 --- a/test/conformance/ingress/util.go +++ b/test/conformance/ingress/util.go @@ -861,10 +861,10 @@ func setDefaultsForTest(ing *v1alpha1.Ingress) { } } -func createIngressReadyDialContext(ctx context.Context, t *testing.T, clients *test.Clients, spec v1alpha1.IngressSpec) (*v1alpha1.Ingress, func(context.Context, string, string) (net.Conn, error), context.CancelFunc) { +func createIngressReadyDialContext(ctx context.Context, t *testing.T, clients *test.Clients, spec v1alpha1.IngressSpec, io ...Option) (*v1alpha1.Ingress, func(context.Context, string, string) (net.Conn, error), context.CancelFunc) { t.Helper() - ing, cancel := CreateIngress(ctx, t, clients, spec) + ing, cancel := CreateIngress(ctx, t, clients, spec, io...) ingName := ktypes.NamespacedName{Name: ing.Name, Namespace: ing.Namespace} if err := WaitForIngressState(ctx, clients.NetworkingClient, ing.Name, IsIngressReady, t.Name()); err != nil { @@ -889,10 +889,20 @@ func CreateIngressReady(ctx context.Context, t *testing.T, clients *test.Clients } func CreateIngressReadyWithTLS(ctx context.Context, t *testing.T, clients *test.Clients, spec v1alpha1.IngressSpec, tlsConfig *tls.Config) (*v1alpha1.Ingress, *http.Client, context.CancelFunc) { + return CreateIngressReadyWithTLSAndOptions(ctx, t, clients, spec, tlsConfig) +} + +// CreateIngressReadyWithOptions creates a ready Ingress and applies the provided options. +func CreateIngressReadyWithOptions(ctx context.Context, t *testing.T, clients *test.Clients, spec v1alpha1.IngressSpec, io ...Option) (*v1alpha1.Ingress, *http.Client, context.CancelFunc) { + return CreateIngressReadyWithTLSAndOptions(ctx, t, clients, spec, nil, io...) +} + +// CreateIngressReadyWithTLSAndOptions creates a ready Ingress with TLS and applies the provided options. +func CreateIngressReadyWithTLSAndOptions(ctx context.Context, t *testing.T, clients *test.Clients, spec v1alpha1.IngressSpec, tlsConfig *tls.Config, io ...Option) (*v1alpha1.Ingress, *http.Client, context.CancelFunc) { t.Helper() // Create a client with a dialer based on the Ingress' public load balancer. - ing, dialer, cancel := createIngressReadyDialContext(ctx, t, clients, spec) + ing, dialer, cancel := createIngressReadyDialContext(ctx, t, clients, spec, io...) return ing, &http.Client{ Transport: &uaRoundTripper{