Skip to content
This repository was archived by the owner on Apr 17, 2025. It is now read-only.

Commit 4af5be4

Browse files
committed
Replace included-namespace VWH rules with MWH
Part of 13. Fixes 37. Add mutating webhook to set the correct `included-namespace` label on non-excluded namespaces and ensure it's removed from any excluded namespace. Update the namespace tree label update logs and function name in the HC reconciler to make it clear that the HC reconciler only updates the tree labels and won't update included-namespace label if the MWH works fine. Remove the `illegalIncludedNamespace` checks in the namespace validator. Tested by `make test` with new test cases. Also tested manually by applying namespace manifests with different labels, viewed the logs and verified the MWH works fine.
1 parent ba15870 commit 4af5be4

File tree

9 files changed

+182
-112
lines changed

9 files changed

+182
-112
lines changed

cmd/manager/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
v1a2 "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
4242
"sigs.k8s.io/hierarchical-namespaces/internal/config"
4343
"sigs.k8s.io/hierarchical-namespaces/internal/forest"
44+
"sigs.k8s.io/hierarchical-namespaces/internal/mutator"
4445
"sigs.k8s.io/hierarchical-namespaces/internal/reconcilers"
4546
"sigs.k8s.io/hierarchical-namespaces/internal/stats"
4647
"sigs.k8s.io/hierarchical-namespaces/internal/validators"
@@ -213,6 +214,10 @@ func startControllers(mgr ctrl.Manager, certsCreated chan struct{}) {
213214
validators.Create(mgr, f)
214215
}
215216

217+
// Create mutating admission controllers.
218+
setupLog.Info("Registering mutating webhook")
219+
mutator.Create(mgr)
220+
216221
// Create all reconciling controllers
217222
setupLog.Info("Creating controllers", "maxReconciles", maxReconciles)
218223
if err := reconcilers.Create(mgr, f, maxReconciles, false); err != nil {

config/webhook/manifests.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
11

2+
---
3+
apiVersion: admissionregistration.k8s.io/v1
4+
kind: MutatingWebhookConfiguration
5+
metadata:
6+
creationTimestamp: null
7+
name: mutating-webhook-configuration
8+
webhooks:
9+
- admissionReviewVersions:
10+
- v1
11+
- v1beta1
12+
clientConfig:
13+
service:
14+
name: webhook-service
15+
namespace: system
16+
path: /mutate-namespace-included-label
17+
failurePolicy: Ignore
18+
name: namespacelabel.hnc.x-k8s.io
19+
rules:
20+
- apiGroups:
21+
- ""
22+
apiVersions:
23+
- v1
24+
operations:
25+
- CREATE
26+
- UPDATE
27+
resources:
28+
- namespaces
29+
sideEffects: None
30+
231
---
332
apiVersion: admissionregistration.k8s.io/v1
433
kind: ValidatingWebhookConfiguration
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package mutator
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/go-logr/logr"
10+
corev1 "k8s.io/api/core/v1"
11+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
12+
13+
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
14+
"sigs.k8s.io/hierarchical-namespaces/internal/config"
15+
)
16+
17+
// NamespaceIncludedLabelServingPath is where the mutator will run. Must be kept
18+
// in sync with the kubebuilder markers below.
19+
const (
20+
NamespaceIncludedLabelServingPath = "/mutate-namespace-included-label"
21+
)
22+
23+
// Note: the mutating webhook FAILS OPEN. This means that if the webhook goes
24+
// down, all further changes are allowed. (An empty line has to be kept below
25+
// the kubebuilder marker for the controller-gen to generate manifests.)
26+
//
27+
// +kubebuilder:webhook:admissionReviewVersions=v1;v1beta1,path=/mutate-namespace-included-label,mutating=true,failurePolicy=ignore,groups="",resources=namespaces,sideEffects=None,verbs=create;update,versions=v1,name=namespacelabel.hnc.x-k8s.io
28+
29+
type NamespaceIncludedLabel struct {
30+
Log logr.Logger
31+
decoder *admission.Decoder
32+
}
33+
34+
// Handle implements the mutating webhook.
35+
func (n *NamespaceIncludedLabel) Handle(ctx context.Context, req admission.Request) admission.Response {
36+
log := n.Log.WithValues("namespace", req.Name)
37+
ns := &corev1.Namespace{}
38+
err := n.decoder.Decode(req, ns)
39+
if err != nil {
40+
return admission.Errored(http.StatusBadRequest, err)
41+
}
42+
log.V(1).Info(fmt.Sprintf("%+v\n", ns))
43+
44+
n.handle(log, ns)
45+
marshaledNS, err := json.Marshal(ns)
46+
if err != nil {
47+
return admission.Errored(http.StatusInternalServerError, err)
48+
}
49+
return admission.PatchResponseFromRaw(req.Object.Raw, marshaledNS)
50+
}
51+
52+
// handle implements the non-boilerplate logic of this mutator, allowing it to
53+
// be more easily unit tested (ie without constructing a full admission.Request).
54+
func (n *NamespaceIncludedLabel) handle(log logr.Logger, ns *corev1.Namespace) {
55+
isExcluded := config.ExcludedNamespaces[ns.Name]
56+
57+
// Ensure the labels map is not nil and remove the included-namespace label if
58+
// it exists.
59+
if ns.Labels == nil {
60+
ns.Labels = map[string]string{}
61+
}
62+
delete(ns.Labels, api.LabelIncludedNamespace)
63+
64+
if !isExcluded {
65+
log.V(1).Info("Not an excluded namespace; set included-namespace label.")
66+
ns.Labels[api.LabelIncludedNamespace] = "true"
67+
} else {
68+
// The included-namespace label is removed earlier.
69+
log.V(1).Info("Excluded namespace detected; clean included-namespace label.")
70+
}
71+
}
72+
73+
// InjectDecoder injects the decoder.
74+
func (n *NamespaceIncludedLabel) InjectDecoder(d *admission.Decoder) error {
75+
n.decoder = d
76+
return nil
77+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package mutator
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/gomega"
7+
corev1 "k8s.io/api/core/v1"
8+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
9+
10+
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
11+
"sigs.k8s.io/hierarchical-namespaces/internal/config"
12+
)
13+
14+
func TestMutateNamespaceIncludedLabel(t *testing.T) {
15+
m := &NamespaceIncludedLabel{}
16+
l := zap.New()
17+
config.ExcludedNamespaces = map[string]bool{"excluded": true}
18+
19+
tests := []struct {
20+
name string
21+
nsn string
22+
lval string
23+
expectlval string
24+
}{
25+
{name: "Should set label on non-excluded namespace without label", nsn: "a", expectlval: "true"},
26+
{name: "Should keep label on non-excluded namespace with label (e.g. from a yaml file)", nsn: "a", lval: "true", expectlval: "true"},
27+
{name: "Should set correct label on non-excluded namespace with label with wrong value(e.g. from a yaml file)", nsn: "a", lval: "wrong", expectlval: "true"},
28+
{name: "Should keep no label on excluded namespace without label", nsn: "excluded"},
29+
{name: "Should remove label on excluded namespace with label (e.g. from a yaml file)", nsn: "excluded", lval: "true"},
30+
{name: "Should remove label on excluded namespace with label with wrong value (e.g. from a yaml file)", nsn: "excluded", lval: "wrong"},
31+
}
32+
for _, tc := range tests {
33+
t.Run(tc.name, func(t *testing.T) {
34+
g := NewWithT(t)
35+
36+
nsInst := &corev1.Namespace{}
37+
nsInst.Name = tc.nsn
38+
if tc.lval != "" {
39+
nsInst.SetLabels(map[string]string{api.LabelIncludedNamespace: tc.lval})
40+
}
41+
42+
// Test
43+
m.handle(l, nsInst)
44+
45+
// Report
46+
g.Expect(nsInst.Labels[api.LabelIncludedNamespace]).Should(Equal(tc.expectlval))
47+
})
48+
}
49+
}

internal/mutator/setup.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package mutator
2+
3+
import (
4+
ctrl "sigs.k8s.io/controller-runtime"
5+
"sigs.k8s.io/controller-runtime/pkg/webhook"
6+
)
7+
8+
// Create creates the mutator. This function is called from main.go.
9+
func Create(mgr ctrl.Manager) {
10+
// Create mutator for namespace `included-namespace` label.
11+
mgr.GetWebhookServer().Register(NamespaceIncludedLabelServingPath, &webhook.Admission{Handler: &NamespaceIncludedLabel{
12+
Log: ctrl.Log.WithName("mutators").WithName("NamespaceIncludedLabel"),
13+
}})
14+
}

internal/reconcilers/hierarchy_config.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ func (r *HierarchyConfigReconciler) syncWithForest(log logr.Logger, nsInst *core
298298
r.syncConditions(log, inst, ns, deletingCRD, hadCrit)
299299

300300
// Sync the tree labels. This should go last since it can depend on the conditions.
301-
nsCustomerLabelUpdated := r.syncLabel(log, nsInst, ns)
301+
nsCustomerLabelUpdated := r.syncTreeLabels(log, nsInst, ns)
302302

303303
return initial || nsCustomerLabelUpdated
304304
}
@@ -461,8 +461,8 @@ func (r *HierarchyConfigReconciler) syncAnchors(log logr.Logger, ns *forest.Name
461461
}
462462
}
463463

464-
// Sync namespace tree labels and other labels. Return true if the labels are updated.
465-
func (r *HierarchyConfigReconciler) syncLabel(log logr.Logger, nsInst *corev1.Namespace, ns *forest.Namespace) bool {
464+
// Sync namespace tree labels. Return true if the labels are updated.
465+
func (r *HierarchyConfigReconciler) syncTreeLabels(log logr.Logger, nsInst *corev1.Namespace, ns *forest.Namespace) bool {
466466
if ns.IsExternal() {
467467
metadata.SetLabel(nsInst, nsInst.Name+api.LabelTreeDepthSuffix, "0")
468468
return false
@@ -504,7 +504,7 @@ func (r *HierarchyConfigReconciler) syncLabel(log logr.Logger, nsInst *corev1.Na
504504
// Update the labels in the forest so that we can quickly access the labels and
505505
// compare if they match the given selector
506506
if ns.SetLabels(nsInst.Labels) {
507-
log.Info("Namespace labels have been updated.")
507+
log.Info("Namespace tree labels have been updated.")
508508
return true
509509
}
510510
return false

internal/validators/namespace.go

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import (
99
corev1 "k8s.io/api/core/v1"
1010
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1111
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
12-
"sigs.k8s.io/hierarchical-namespaces/internal/config"
13-
1412
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
1513
"sigs.k8s.io/hierarchical-namespaces/internal/forest"
1614
)
@@ -76,18 +74,12 @@ func (v *Namespace) handle(req *nsRequest) admission.Response {
7674

7775
switch req.op {
7876
case k8sadm.Create:
79-
if rsp := v.illegalIncludedNamespaceLabel(req); !rsp.Allowed {
80-
return rsp
81-
}
8277
// This check only applies to the Create operation since namespace name
8378
// cannot be updated.
8479
if rsp := v.nameExistsInExternalHierarchy(req); !rsp.Allowed {
8580
return rsp
8681
}
8782
case k8sadm.Update:
88-
if rsp := v.illegalIncludedNamespaceLabel(req); !rsp.Allowed {
89-
return rsp
90-
}
9183
// This check only applies to the Update operation. Creating a namespace
9284
// with external manager is allowed and we will prevent this conflict by not
9385
// allowing setting a parent when validating the HierarchyConfiguration.
@@ -106,49 +98,6 @@ func (v *Namespace) handle(req *nsRequest) admission.Response {
10698
return allow("")
10799
}
108100

109-
// illegalIncludedNamespaceLabel checks if there's any illegal use of the
110-
// included-namespace label on namespaces.
111-
// TODO these validating rules will be removed and converted into a mutating
112-
// webhook. See https://github.com/kubernetes-sigs/hierarchical-namespaces/issues/37
113-
func (v *Namespace) illegalIncludedNamespaceLabel(req *nsRequest) admission.Response {
114-
labelValue, hasLabel := req.ns.Labels[api.LabelIncludedNamespace]
115-
isExcluded := config.ExcludedNamespaces[req.ns.Name]
116-
117-
// An excluded namespace should not have the included-namespace label.
118-
//
119-
// Note: this only blocks the request if the excluded namespace is newly
120-
// created or is updated with a newly added included-namespace label because
121-
// existing illegal included-namespace label should have already been removed
122-
// by our reconciler. For example, even when the VWHConfiguration is removed,
123-
// adding the label to an excluded namespace would pass but the label is
124-
// immediately removed; when the VWHConfiguration is there but the reconcilers
125-
// are down, any request gets denied anyway.
126-
if isExcluded && hasLabel {
127-
// Todo add label concept and add `See https://github.com/kubernetes-sigs/hierarchical-namespaces/blob/master/docs/user-guide/concepts.md#included-namespace-label for detail`
128-
// to the message below. Github issue - https://github.com/kubernetes-sigs/hierarchical-namespaces/issues/9
129-
msg := fmt.Sprintf("You cannot enforce webhook rules on this excluded namespace using the %q label.", api.LabelIncludedNamespace)
130-
return deny(metav1.StatusReasonForbidden, msg)
131-
}
132-
133-
// An included-namespace should not have the included-namespace label with a
134-
// value other than "true" at any time. We will allow updating an included
135-
// namespace by removing its included-namespace label, since people or system
136-
// may apply namespace manifests (without the label) automatically and it
137-
// doesn't make sense to block their attempts after the first time.
138-
//
139-
// Note: this only blocks the request if the included namespace is just
140-
// created or updated with a wrong value for the included-namespace label,
141-
// because our reconciler should already set it correctly before the request.
142-
if !isExcluded && hasLabel && labelValue != "true" {
143-
// Todo add label concept and add `See https://github.com/kubernetes-sigs/hierarchical-namespaces/blob/master/docs/user-guide/concepts.md#included-namespace-label for detail`
144-
// to the message below. Github issue - https://github.com/kubernetes-sigs/hierarchical-namespaces/issues/9
145-
msg := fmt.Sprintf("You cannot unset the value of the %q label. Only HNC can edit this label on namespaces.", api.LabelIncludedNamespace)
146-
return deny(metav1.StatusReasonForbidden, msg)
147-
}
148-
149-
return allow("")
150-
}
151-
152101
func (v *Namespace) nameExistsInExternalHierarchy(req *nsRequest) admission.Response {
153102
for _, nm := range v.Forest.GetNamespaceNames() {
154103
if _, ok := v.Forest.Get(nm).ExternalTreeLabels[req.ns.Name]; ok {

internal/validators/namespace_test.go

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
. "github.com/onsi/gomega"
77
k8sadm "k8s.io/api/admission/v1"
88
corev1 "k8s.io/api/core/v1"
9-
"sigs.k8s.io/hierarchical-namespaces/internal/config"
109

1110
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
1211
"sigs.k8s.io/hierarchical-namespaces/internal/foresttest"
@@ -227,59 +226,3 @@ func setSubAnnotation(ns *corev1.Namespace, pnm string) {
227226
}
228227
ns.SetAnnotations(a)
229228
}
230-
231-
func TestIllegalIncludedNamespaceNamespace(t *testing.T) {
232-
f := foresttest.Create("-a-c") // a <- b; c <- d
233-
vns := &Namespace{Forest: f}
234-
config.ExcludedNamespaces["excluded"] = true
235-
236-
tests := []struct {
237-
name string
238-
excludedNS bool
239-
lval string
240-
op k8sadm.Operation
241-
fail bool
242-
}{
243-
{name: "allow creating non-excluded namespace without label", op: k8sadm.Create},
244-
{name: "allow creating non-excluded namespace with label (e.g. from a yaml file)", lval: "true", op: k8sadm.Create},
245-
{name: "deny creating non-excluded namespace with label with wrong value(e.g. from a yaml file)", lval: "else", op: k8sadm.Create, fail: true},
246-
{name: "allow creating excluded namespace without label", excludedNS: true, op: k8sadm.Create},
247-
{name: "deny creating excluded namespace with label", excludedNS: true, lval: "true", op: k8sadm.Create, fail: true},
248-
249-
{name: "allow updating non-excluded namespace with correct label (which is actually set by HC reconciler)", lval: "true", op: k8sadm.Update},
250-
{name: "allow updating non-excluded namespace without label", op: k8sadm.Update},
251-
{name: "deny updating non-excluded namespace with incorrect label", lval: "else", op: k8sadm.Update, fail: true},
252-
{name: "allow updating excluded namespace without label", excludedNS: true, op: k8sadm.Update},
253-
{name: "deny updating excluded namespace with label", excludedNS: true, lval: "true", op: k8sadm.Update, fail: true},
254-
255-
{name: "allow deleting non-excluded namespace without label", op: k8sadm.Delete},
256-
{name: "allow deleting non-excluded namespace with label", lval: "true", op: k8sadm.Delete},
257-
{name: "allow creating excluded namespace without label", excludedNS: true, op: k8sadm.Delete},
258-
{name: "allow creating excluded namespace with label", excludedNS: true, lval: "true", op: k8sadm.Delete},
259-
}
260-
for _, tc := range tests {
261-
t.Run(tc.name, func(t *testing.T) {
262-
g := NewWithT(t)
263-
264-
nsInst := &corev1.Namespace{}
265-
nsInst.Name = "a"
266-
if tc.excludedNS {
267-
nsInst.Name = "excluded"
268-
}
269-
if tc.lval != "" {
270-
nsInst.SetLabels(map[string]string{api.LabelIncludedNamespace: tc.lval})
271-
}
272-
req := &nsRequest{
273-
ns: nsInst,
274-
op: tc.op,
275-
}
276-
277-
// Test
278-
got := vns.handle(req)
279-
280-
// Report
281-
logResult(t, got.AdmissionResponse.Result)
282-
g.Expect(got.AdmissionResponse.Allowed).ShouldNot(Equal(tc.fail))
283-
})
284-
}
285-
}

internal/validators/setup.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
const (
1515
serviceName = "hnc-webhook-service"
1616
vwhName = "hnc-validating-webhook-configuration"
17+
mwhName = "hnc-mutating-webhook-configuration"
1718
caName = "hnc-ca"
1819
caOrganization = "hnc"
1920
secretNamespace = "hnc-system"
@@ -45,6 +46,9 @@ func CreateCertsIfNeeded(mgr ctrl.Manager, novalidation, internalCert, restartOn
4546
Webhooks: []cert.WebhookInfo{{
4647
Type: cert.Validating,
4748
Name: vwhName,
49+
}, {
50+
Type: cert.Mutating,
51+
Name: mwhName,
4852
}},
4953
RestartOnSecretRefresh: restartOnSecretRefresh,
5054
})

0 commit comments

Comments
 (0)