Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/apis/networking/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
153 changes: 153 additions & 0 deletions pkg/ingress/tags.go
Original file line number Diff line number Diff line change
@@ -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
}
203 changes: 203 additions & 0 deletions pkg/ingress/tags_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading