Skip to content

Commit 17e4e48

Browse files
committed
add shared tag-to-host helpers
Signed-off-by: kahirokunn <okinakahiro@gmail.com>
1 parent 0055e92 commit 17e4e48

File tree

6 files changed

+462
-6
lines changed

6 files changed

+462
-6
lines changed

pkg/apis/networking/register.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const (
4242
RolloutAnnotationKey = GroupName + "/rollout"
4343

4444
// TagToHostAnnotationKey is the annotation key used for storing a JSON map of
45-
// tags to host names
45+
// tag names to hostnames that should be routed to that tag.
4646
TagToHostAnnotationKey = GroupName + "/tag-to-host"
4747
)
4848

pkg/ingress/tags.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
Copyright 2026 The Knative Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package ingress
18+
19+
import (
20+
"encoding/json"
21+
"strings"
22+
23+
"k8s.io/apimachinery/pkg/util/sets"
24+
apisnet "knative.dev/networking/pkg/apis/networking"
25+
"knative.dev/networking/pkg/apis/networking/v1alpha1"
26+
"knative.dev/networking/pkg/http/header"
27+
"knative.dev/pkg/network"
28+
)
29+
30+
// TagToHosts parses the tag-to-host annotation into sets keyed by tag.
31+
// Invalid annotations and empty host lists are ignored.
32+
func TagToHosts(ing *v1alpha1.Ingress) map[string]sets.Set[string] {
33+
serialized := ing.GetAnnotations()[apisnet.TagToHostAnnotationKey]
34+
if serialized == "" {
35+
return nil
36+
}
37+
38+
parsed := make(map[string][]string)
39+
if err := json.Unmarshal([]byte(serialized), &parsed); err != nil {
40+
return nil
41+
}
42+
43+
tagToHosts := make(map[string]sets.Set[string], len(parsed))
44+
for tag, hosts := range parsed {
45+
if len(hosts) == 0 {
46+
continue
47+
}
48+
tagToHosts[tag] = sets.New(hosts...)
49+
}
50+
return tagToHosts
51+
}
52+
53+
// HostsForTag returns the hostnames for a tag filtered by ingress visibility.
54+
func HostsForTag(tag string, visibility v1alpha1.IngressVisibility, tagToHosts map[string]sets.Set[string]) sets.Set[string] {
55+
if len(tagToHosts) == 0 {
56+
return sets.New[string]()
57+
}
58+
hosts, ok := tagToHosts[tag]
59+
if !ok {
60+
return sets.New[string]()
61+
}
62+
63+
switch visibility {
64+
case v1alpha1.IngressVisibilityClusterLocal:
65+
return ExpandedHosts(filterLocalHostnames(hosts))
66+
default:
67+
return ExpandedHosts(filterNonLocalHostnames(hosts))
68+
}
69+
}
70+
71+
// MakeTagHostIngressPath clones a header-based tag path into a host-based one.
72+
func MakeTagHostIngressPath(path *v1alpha1.HTTPIngressPath, tag string) *v1alpha1.HTTPIngressPath {
73+
tagPath := path.DeepCopy()
74+
if tagPath.Headers != nil {
75+
delete(tagPath.Headers, header.RouteTagKey)
76+
if len(tagPath.Headers) == 0 {
77+
tagPath.Headers = nil
78+
}
79+
}
80+
if tagPath.AppendHeaders == nil {
81+
tagPath.AppendHeaders = make(map[string]string, 1)
82+
}
83+
tagPath.AppendHeaders[header.RouteTagKey] = tag
84+
return tagPath
85+
}
86+
87+
// RouteTagHeaderValue returns the value of the route tag header match.
88+
func RouteTagHeaderValue(headers map[string]v1alpha1.HeaderMatch) string {
89+
if len(headers) == 0 {
90+
return ""
91+
}
92+
match, ok := headers[header.RouteTagKey]
93+
if !ok {
94+
return ""
95+
}
96+
return match.Exact
97+
}
98+
99+
// RouteTagAppendValue returns the value of the route tag append header.
100+
func RouteTagAppendValue(headers map[string]string) string {
101+
if len(headers) == 0 {
102+
return ""
103+
}
104+
return headers[header.RouteTagKey]
105+
}
106+
107+
// RouteHosts returns the hostnames addressed by a path, including synthesized
108+
// tag hosts for append-header-only tag routes.
109+
func RouteHosts(ruleHosts sets.Set[string], path *v1alpha1.HTTPIngressPath, visibility v1alpha1.IngressVisibility, tagToHosts map[string]sets.Set[string]) sets.Set[string] {
110+
hosts := ruleHosts
111+
if tag := RouteTagAppendValue(path.AppendHeaders); tag != "" && RouteTagHeaderValue(path.Headers) == "" {
112+
hosts = hosts.Union(HostsForTag(tag, visibility, tagToHosts))
113+
}
114+
return hosts
115+
}
116+
117+
// HostRouteTags returns the set of tags already represented as host-based paths.
118+
func HostRouteTags(rule *v1alpha1.IngressRule) sets.Set[string] {
119+
tags := sets.New[string]()
120+
if rule.HTTP == nil {
121+
return tags
122+
}
123+
for _, path := range rule.HTTP.Paths {
124+
tag := RouteTagAppendValue(path.AppendHeaders)
125+
if tag == "" || RouteTagHeaderValue(path.Headers) != "" {
126+
continue
127+
}
128+
tags.Insert(tag)
129+
}
130+
return tags
131+
}
132+
133+
func filterLocalHostnames(hosts sets.Set[string]) sets.Set[string] {
134+
localSvcSuffix := ".svc." + network.GetClusterDomainName()
135+
retained := sets.New[string]()
136+
for _, host := range sets.List(hosts) {
137+
if strings.HasSuffix(host, localSvcSuffix) {
138+
retained.Insert(host)
139+
}
140+
}
141+
return retained
142+
}
143+
144+
func filterNonLocalHostnames(hosts sets.Set[string]) sets.Set[string] {
145+
localSvcSuffix := ".svc." + network.GetClusterDomainName()
146+
retained := sets.New[string]()
147+
for _, host := range sets.List(hosts) {
148+
if !strings.HasSuffix(host, localSvcSuffix) {
149+
retained.Insert(host)
150+
}
151+
}
152+
return retained
153+
}

pkg/ingress/tags_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
Copyright 2026 The Knative Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package ingress
18+
19+
import (
20+
"testing"
21+
22+
"github.com/google/go-cmp/cmp"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/util/sets"
25+
apisnet "knative.dev/networking/pkg/apis/networking"
26+
"knative.dev/networking/pkg/apis/networking/v1alpha1"
27+
"knative.dev/networking/pkg/http/header"
28+
)
29+
30+
func TestTagToHosts(t *testing.T) {
31+
tests := []struct {
32+
name string
33+
ing *v1alpha1.Ingress
34+
want map[string]sets.Set[string]
35+
}{{
36+
name: "missing annotation",
37+
ing: &v1alpha1.Ingress{},
38+
}, {
39+
name: "invalid annotation",
40+
ing: &v1alpha1.Ingress{
41+
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
42+
apisnet.TagToHostAnnotationKey: "{",
43+
}},
44+
},
45+
}, {
46+
name: "valid annotation",
47+
ing: &v1alpha1.Ingress{
48+
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
49+
apisnet.TagToHostAnnotationKey: `{"blue":["blue.example.com","blue.example.com"],"green":[],"internal":["green.test-ns.svc.cluster.local"]}`,
50+
}},
51+
},
52+
want: map[string]sets.Set[string]{
53+
"blue": sets.New("blue.example.com"),
54+
"internal": sets.New("green.test-ns.svc.cluster.local"),
55+
},
56+
}}
57+
58+
for _, test := range tests {
59+
t.Run(test.name, func(t *testing.T) {
60+
if diff := cmp.Diff(asSortedSlices(test.want), asSortedSlices(TagToHosts(test.ing))); diff != "" {
61+
t.Fatalf("TagToHosts diff (-want,+got):\n%s", diff)
62+
}
63+
})
64+
}
65+
}
66+
67+
func TestHostsForTag(t *testing.T) {
68+
tagToHosts := map[string]sets.Set[string]{
69+
"blue": sets.New(
70+
"blue.example.com",
71+
"blue.test-ns.svc.cluster.local",
72+
),
73+
}
74+
75+
if diff := cmp.Diff(
76+
sets.List(sets.New("blue.example.com")),
77+
sets.List(HostsForTag("blue", v1alpha1.IngressVisibilityExternalIP, tagToHosts)),
78+
); diff != "" {
79+
t.Fatalf("external HostsForTag diff (-want,+got):\n%s", diff)
80+
}
81+
82+
if diff := cmp.Diff(
83+
sets.List(sets.New(
84+
"blue.test-ns",
85+
"blue.test-ns.svc",
86+
"blue.test-ns.svc.cluster.local",
87+
)),
88+
sets.List(HostsForTag("blue", v1alpha1.IngressVisibilityClusterLocal, tagToHosts)),
89+
); diff != "" {
90+
t.Fatalf("cluster-local HostsForTag diff (-want,+got):\n%s", diff)
91+
}
92+
}
93+
94+
func TestMakeTagHostIngressPath(t *testing.T) {
95+
original := &v1alpha1.HTTPIngressPath{
96+
Headers: map[string]v1alpha1.HeaderMatch{
97+
header.RouteTagKey: {Exact: "blue"},
98+
"X-Test": {Exact: "preserved"},
99+
},
100+
AppendHeaders: map[string]string{
101+
"X-Existing": "value",
102+
},
103+
}
104+
105+
got := MakeTagHostIngressPath(original, "blue")
106+
107+
if diff := cmp.Diff(
108+
map[string]string{
109+
"X-Test": headerMatchValue(got.Headers["X-Test"]),
110+
},
111+
map[string]string{
112+
"X-Test": "preserved",
113+
},
114+
); diff != "" {
115+
t.Fatalf("MakeTagHostIngressPath headers diff (-want,+got):\n%s", diff)
116+
}
117+
118+
if got.AppendHeaders[header.RouteTagKey] != "blue" {
119+
t.Fatalf("MakeTagHostIngressPath append tag = %q, want %q", got.AppendHeaders[header.RouteTagKey], "blue")
120+
}
121+
if _, ok := original.AppendHeaders[header.RouteTagKey]; ok {
122+
t.Fatal("MakeTagHostIngressPath mutated original append headers")
123+
}
124+
if _, ok := got.Headers[header.RouteTagKey]; ok {
125+
t.Fatal("MakeTagHostIngressPath kept route tag header match")
126+
}
127+
}
128+
129+
func TestRouteHosts(t *testing.T) {
130+
ruleHosts := sets.New("route.example.com")
131+
tagToHosts := map[string]sets.Set[string]{
132+
"blue": sets.New("blue.example.com"),
133+
}
134+
135+
hostPath := &v1alpha1.HTTPIngressPath{
136+
AppendHeaders: map[string]string{
137+
header.RouteTagKey: "blue",
138+
},
139+
}
140+
if diff := cmp.Diff(
141+
sets.List(sets.New("blue.example.com", "route.example.com")),
142+
sets.List(RouteHosts(ruleHosts, hostPath, v1alpha1.IngressVisibilityExternalIP, tagToHosts)),
143+
); diff != "" {
144+
t.Fatalf("host RouteHosts diff (-want,+got):\n%s", diff)
145+
}
146+
147+
headerPath := &v1alpha1.HTTPIngressPath{
148+
Headers: map[string]v1alpha1.HeaderMatch{
149+
header.RouteTagKey: {Exact: "blue"},
150+
},
151+
AppendHeaders: map[string]string{
152+
header.RouteTagKey: "blue",
153+
},
154+
}
155+
if diff := cmp.Diff(
156+
sets.List(ruleHosts),
157+
sets.List(RouteHosts(ruleHosts, headerPath, v1alpha1.IngressVisibilityExternalIP, tagToHosts)),
158+
); diff != "" {
159+
t.Fatalf("header RouteHosts diff (-want,+got):\n%s", diff)
160+
}
161+
}
162+
163+
func TestHostRouteTags(t *testing.T) {
164+
rule := &v1alpha1.IngressRule{
165+
HTTP: &v1alpha1.HTTPIngressRuleValue{
166+
Paths: []v1alpha1.HTTPIngressPath{{
167+
AppendHeaders: map[string]string{
168+
header.RouteTagKey: "blue",
169+
},
170+
}, {
171+
Headers: map[string]v1alpha1.HeaderMatch{
172+
header.RouteTagKey: {Exact: "green"},
173+
},
174+
}, {
175+
Headers: map[string]v1alpha1.HeaderMatch{
176+
header.RouteTagKey: {Exact: "red"},
177+
},
178+
AppendHeaders: map[string]string{
179+
header.RouteTagKey: "red",
180+
},
181+
}},
182+
},
183+
}
184+
185+
if diff := cmp.Diff(sets.List(sets.New("blue")), sets.List(HostRouteTags(rule))); diff != "" {
186+
t.Fatalf("HostRouteTags diff (-want,+got):\n%s", diff)
187+
}
188+
}
189+
190+
func asSortedSlices(in map[string]sets.Set[string]) map[string][]string {
191+
if len(in) == 0 {
192+
return nil
193+
}
194+
out := make(map[string][]string, len(in))
195+
for tag, hosts := range in {
196+
out[tag] = sets.List(hosts)
197+
}
198+
return out
199+
}
200+
201+
func headerMatchValue(match v1alpha1.HeaderMatch) string {
202+
return match.Exact
203+
}

0 commit comments

Comments
 (0)