Skip to content

Commit 4a6be6d

Browse files
author
joehu
committed
Add dynamic namespace selection by label
Introduce --namespace-label-selector with dynamic per-namespace watch sessions. Namespace label changes update the watched resource set without restarting the controller, while whitelist and blacklist configurations retain precedence. Add stale-session protection, lifecycle handling, documentation, and unit and end-to-end coverage for Kubernetes and Gateway API resources. Refs #853.
1 parent e064fe1 commit 4a6be6d

53 files changed

Lines changed: 5390 additions & 223 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
kind: Deployment
2+
apiVersion: apps/v1
3+
metadata:
4+
name: http-echo
5+
spec:
6+
replicas: 1
7+
selector:
8+
matchLabels:
9+
app: http-echo
10+
template:
11+
metadata:
12+
labels:
13+
app: http-echo
14+
spec:
15+
containers:
16+
- name: http-echo
17+
image: haproxytech/http-echo:latest
18+
imagePullPolicy: IfNotPresent
19+
ports:
20+
- name: http
21+
containerPort: 8888
22+
protocol: TCP
23+
---
24+
kind: Service
25+
apiVersion: v1
26+
metadata:
27+
name: http-echo
28+
spec:
29+
ports:
30+
- name: http
31+
protocol: TCP
32+
port: 80
33+
targetPort: http
34+
selector:
35+
app: http-echo
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
kind: Ingress
3+
apiVersion: networking.k8s.io/v1
4+
metadata:
5+
name: http-echo
6+
spec:
7+
ingressClassName: haproxy
8+
rules:
9+
- host: {{ .Host }}
10+
http:
11+
paths:
12+
- path: /
13+
pathType: Prefix
14+
backend:
15+
service:
16+
name: http-echo
17+
port:
18+
name: http
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
apiVersion: ingress.v1.haproxy.org/v1
2+
kind: TCP
3+
metadata:
4+
name: namespace-selector-late-tcp
5+
annotations:
6+
ingress.class: haproxy
7+
spec:
8+
- name: {{ .Namespace }}-late-http
9+
frontend:
10+
name: {{ .Namespace }}-late-http
11+
binds:
12+
- name: v4
13+
port: {{ .Port }}
14+
service:
15+
name: http-echo
16+
port: 80
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
apiVersion: ingress.v3.haproxy.org/v3
2+
kind: TCP
3+
metadata:
4+
name: namespace-selector-tcp
5+
annotations:
6+
ingress.class: haproxy
7+
spec:
8+
- name: namespace-selector-http
9+
frontend:
10+
name: namespace-selector-http
11+
binds:
12+
v4:
13+
name: v4
14+
port: 32766
15+
service:
16+
name: http-echo
17+
port: 80
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
// Copyright 2019 HAProxy Technologies LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build e2e_sequential
16+
17+
package namespaceselector
18+
19+
import (
20+
"strings"
21+
22+
"github.com/haproxytech/kubernetes-ingress/deploy/tests/e2e"
23+
)
24+
25+
func (suite *NamespaceSelectorSuite) Test_UnlabeledAtStart_Is404() {
26+
suite.waitStatus(suite.client, 404)
27+
suite.waitStatus(suite.foreignClient, 404)
28+
}
29+
30+
// ConfigMap namespace is always watched (local default backend, default cert)
31+
// but its own Ingress stays out of HAProxy until it carries the selector labels.
32+
func (suite *NamespaceSelectorSuite) Test_ConfigMapNamespaceOwnIngressNeedsLabel() {
33+
host := controllerNS + ".cmns.watch.test"
34+
client, err := e2e.NewHTTPClient(host)
35+
suite.Require().NoError(err)
36+
suite.T().Cleanup(func() {
37+
suite.Require().NoError(clearWatchLabel(controllerNS))
38+
for _, kind := range []string{"ingress", "svc", "deploy"} {
39+
out, delErr := kubectl("-n", controllerNS, "delete", kind, "http-echo", "--ignore-not-found=true")
40+
suite.NoError(delErr, out)
41+
}
42+
})
43+
suite.Require().NoError(suite.test.Apply("config/deploy.yaml", controllerNS, nil))
44+
suite.Require().NoError(suite.test.Apply(
45+
"config/ingress.yaml.tmpl",
46+
controllerNS,
47+
struct{ Host string }{Host: host},
48+
))
49+
50+
suite.waitStatus(client, 404)
51+
52+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
53+
suite.waitStatus(suite.client, 200)
54+
suite.waitStatus(client, 404)
55+
56+
suite.Require().NoError(labelNS(controllerNS, "app=watch"))
57+
suite.waitStatus(client, 200)
58+
suite.waitStatus(suite.client, 200)
59+
60+
suite.Require().NoError(clearWatchLabel(controllerNS))
61+
suite.waitStatus(client, 404)
62+
suite.waitStatus(suite.client, 200)
63+
}
64+
65+
func (suite *NamespaceSelectorSuite) Test_LabelStartsWatchWithoutTouchingIngress() {
66+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
67+
suite.waitStatus(suite.client, 200)
68+
suite.waitStatus(suite.foreignClient, 404)
69+
}
70+
71+
func (suite *NamespaceSelectorSuite) Test_TwoMatchingNamespaces() {
72+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
73+
suite.Require().NoError(labelNS(suite.foreignNS, "app=watch"))
74+
suite.waitStatus(suite.client, 200)
75+
suite.waitStatus(suite.foreignClient, 200)
76+
}
77+
78+
func (suite *NamespaceSelectorSuite) Test_PrelabeledNamespacesBootstrapTogether() {
79+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
80+
suite.Require().NoError(labelNS(suite.foreignNS, "app=watch"))
81+
suite.Require().NoError(restartController())
82+
suite.waitStatus(suite.client, 200)
83+
suite.waitStatus(suite.foreignClient, 200)
84+
}
85+
86+
func (suite *NamespaceSelectorSuite) Test_UnlabelDropsRouteAndConfig() {
87+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
88+
suite.waitStatus(suite.client, 200)
89+
90+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app-"))
91+
suite.waitStatus(suite.client, 404)
92+
93+
cfg, err := suite.test.GetIngressControllerFile("/etc/haproxy/haproxy.cfg")
94+
suite.Require().NoError(err)
95+
suite.NotContains(cfg, suite.host)
96+
}
97+
98+
func (suite *NamespaceSelectorSuite) Test_LabelValueChangeDropsRoute() {
99+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
100+
suite.waitStatus(suite.client, 200)
101+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=nowatch"))
102+
suite.waitStatus(suite.client, 404)
103+
}
104+
105+
func (suite *NamespaceSelectorSuite) Test_BounceRelabelRestoresWithoutIngressEdit() {
106+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
107+
suite.waitStatus(suite.client, 200)
108+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app-"))
109+
suite.waitStatus(suite.client, 404)
110+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
111+
suite.waitStatus(suite.client, 200)
112+
}
113+
114+
func (suite *NamespaceSelectorSuite) Test_UnlabelMutationRelabelUsesLatestIngress() {
115+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
116+
suite.waitStatus(suite.client, 200)
117+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app-"))
118+
suite.waitStatus(suite.client, 404)
119+
120+
updatedHost := "updated-" + suite.host
121+
updatedClient, err := e2e.NewHTTPClient(updatedHost)
122+
suite.Require().NoError(err)
123+
suite.Require().NoError(suite.test.Apply(
124+
"config/ingress.yaml.tmpl",
125+
suite.test.GetNS(),
126+
struct{ Host string }{Host: updatedHost},
127+
))
128+
suite.T().Cleanup(func() {
129+
suite.Require().NoError(suite.test.Apply(
130+
"config/ingress.yaml.tmpl",
131+
suite.test.GetNS(),
132+
struct{ Host string }{Host: suite.host},
133+
))
134+
})
135+
136+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
137+
suite.waitStatus(suite.client, 404)
138+
suite.waitStatus(updatedClient, 200)
139+
}
140+
141+
func (suite *NamespaceSelectorSuite) Test_TCPCRLabelUnlabelBounce() {
142+
suite.Require().NoError(suite.test.Apply("config/tcp.yaml", suite.test.GetNS(), nil))
143+
client, err := e2e.NewHTTPClient(suite.host, 32766)
144+
suite.Require().NoError(err)
145+
suite.T().Cleanup(func() {
146+
out, err := kubectl(
147+
"-n", suite.test.GetNS(),
148+
"delete", "tcp", "namespace-selector-tcp",
149+
"--ignore-not-found=true",
150+
)
151+
suite.Require().NoError(err, out)
152+
suite.waitUnavailable(client)
153+
suite.waitNoTCPFrontends()
154+
// The Ingress backend is shared with the TCP CR. Wait until HTTP is
155+
// restored while this namespace is still selected, otherwise the next
156+
// test sees EOF/500 from a leftover TCP-mode backend.
157+
suite.waitStatus(suite.client, 200)
158+
})
159+
160+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
161+
suite.waitStatus(client, 200)
162+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app-"))
163+
suite.waitUnavailable(client)
164+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
165+
suite.waitStatus(client, 200)
166+
}
167+
168+
func (suite *NamespaceSelectorSuite) Test_DeletedNamespaceDropsRoute() {
169+
namespace := suite.test.GetNS() + "-deleted"
170+
out, err := kubectl("create", "ns", namespace)
171+
suite.Require().NoError(err, out)
172+
suite.T().Cleanup(func() {
173+
out, err := kubectl("delete", "ns", namespace, "--ignore-not-found=true", "--wait=false")
174+
suite.Require().NoError(err, out)
175+
})
176+
host := namespace + ".watch.test"
177+
client, err := e2e.NewHTTPClient(host)
178+
suite.Require().NoError(err)
179+
suite.Require().NoError(suite.test.Apply("config/deploy.yaml", namespace, nil))
180+
suite.Require().NoError(suite.test.Apply(
181+
"config/ingress.yaml.tmpl",
182+
namespace,
183+
struct{ Host string }{Host: host},
184+
))
185+
suite.Require().NoError(labelNS(namespace, "app=watch"))
186+
suite.waitStatus(client, 200)
187+
188+
out, err = kubectl("delete", "ns", namespace, "--wait=true", "--timeout=120s")
189+
suite.Require().NoError(err, out)
190+
suite.waitStatus(client, 404)
191+
cfg, err := suite.test.GetIngressControllerFile("/etc/haproxy/haproxy.cfg")
192+
suite.Require().NoError(err)
193+
suite.NotContains(cfg, host)
194+
}
195+
196+
func (suite *NamespaceSelectorSuite) Test_WhitelistPlusSelectorKeepsWhitelist() {
197+
patched := append([]string{}, suite.originalArgs...)
198+
patched = append(patched, "--namespace-whitelist="+suite.test.GetNS(), selectorFlag)
199+
suite.Require().NoError(restoreControllerArgs(patched))
200+
suite.T().Cleanup(func() {
201+
suite.Require().NoError(restoreControllerArgs(suite.originalArgs))
202+
suite.Require().NoError(setControllerSelector())
203+
suite.Require().NoError(waitControllerRollout())
204+
})
205+
// The namespace is deliberately unlabeled. Whitelist must take precedence.
206+
suite.waitStatus(suite.client, 200)
207+
suite.Require().NoError(labelNS(suite.foreignNS, "app=watch"))
208+
suite.waitStatus(suite.foreignClient, 404)
209+
logs, err := kubectl("-n", controllerNS, "logs", "deploy/"+controllerName, "--tail=200")
210+
suite.Require().NoError(err)
211+
suite.True(strings.Contains(logs, "--namespace-label-selector is ignored"), logs)
212+
}
213+
214+
func (suite *NamespaceSelectorSuite) Test_BlacklistPlusSelectorKeepsBlacklist() {
215+
patched := append([]string{}, suite.originalArgs...)
216+
patched = append(patched, "--namespace-blacklist="+suite.foreignNS, selectorFlag)
217+
suite.Require().NoError(restoreControllerArgs(patched))
218+
suite.T().Cleanup(func() {
219+
suite.Require().NoError(restoreControllerArgs(suite.originalArgs))
220+
suite.Require().NoError(setControllerSelector())
221+
suite.Require().NoError(waitControllerRollout())
222+
})
223+
// Selector is ignored: the unlabeled, non-blacklisted namespace is watched.
224+
suite.waitStatus(suite.client, 200)
225+
suite.Require().NoError(labelNS(suite.foreignNS, "app=watch"))
226+
suite.waitStatus(suite.foreignClient, 404)
227+
logs, err := kubectl("-n", controllerNS, "logs", "deploy/"+controllerName, "--tail=200")
228+
suite.Require().NoError(err)
229+
suite.True(strings.Contains(logs, "--namespace-label-selector is ignored"), logs)
230+
}
231+
232+
func (suite *NamespaceSelectorSuite) Test_DormantDeleteIngressRelabelStays404() {
233+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
234+
suite.waitStatus(suite.client, 200)
235+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app-"))
236+
suite.waitStatus(suite.client, 404)
237+
238+
out, err := kubectl("-n", suite.test.GetNS(), "delete", "ingress", "--all", "--wait=true")
239+
suite.Require().NoError(err, out)
240+
suite.T().Cleanup(func() {
241+
suite.Require().NoError(suite.test.Apply(
242+
"config/ingress.yaml.tmpl",
243+
suite.test.GetNS(),
244+
struct{ Host string }{Host: suite.host},
245+
))
246+
})
247+
248+
suite.Require().NoError(labelNS(suite.test.GetNS(), "app=watch"))
249+
suite.waitStatus(suite.client, 404)
250+
}
251+
252+
func (suite *NamespaceSelectorSuite) Test_SameNameNamespaceRecreateAfterDelete() {
253+
namespace := suite.test.GetNS() + "-recreate"
254+
host := namespace + ".watch.test"
255+
client, err := e2e.NewHTTPClient(host)
256+
suite.Require().NoError(err)
257+
suite.T().Cleanup(func() {
258+
out, delErr := kubectl("delete", "ns", namespace, "--ignore-not-found=true", "--wait=false")
259+
suite.Require().NoError(delErr, out)
260+
})
261+
262+
out, err := kubectl("create", "ns", namespace)
263+
suite.Require().NoError(err, out)
264+
suite.Require().NoError(suite.test.Apply("config/deploy.yaml", namespace, nil))
265+
suite.Require().NoError(suite.test.Apply(
266+
"config/ingress.yaml.tmpl",
267+
namespace,
268+
struct{ Host string }{Host: host},
269+
))
270+
suite.Require().NoError(labelNS(namespace, "app=watch"))
271+
suite.waitStatus(client, 200)
272+
273+
snapshot := suite.waitControllerSnapshot()
274+
out, err = kubectl("delete", "ns", namespace, "--wait=true", "--timeout=120s")
275+
suite.Require().NoError(err, out)
276+
suite.waitStatus(client, 404)
277+
278+
out, err = kubectl("create", "ns", namespace)
279+
suite.Require().NoError(err, out)
280+
suite.Require().NoError(suite.test.Apply("config/deploy.yaml", namespace, nil))
281+
suite.Require().NoError(suite.test.Apply(
282+
"config/ingress.yaml.tmpl",
283+
namespace,
284+
struct{ Host string }{Host: host},
285+
))
286+
suite.Require().NoError(labelNS(namespace, "app=watch"))
287+
suite.waitStatus(client, 200)
288+
289+
after := suite.waitControllerSnapshot()
290+
suite.Equal(snapshot.UID, after.UID, "same-name recreate must not restart the controller")
291+
}

0 commit comments

Comments
 (0)