Skip to content

Commit 2520bb6

Browse files
authored
feat: add a webhook that blocks PDB creation in non-reserved namespaces (#546)
1 parent 7d800c9 commit 2520bb6

17 files changed

Lines changed: 517 additions & 20 deletions

cmd/hubagent/options/webhooks.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,15 @@ type WebhookOptions struct {
5252

5353
// Enable workload resources (pods and replicaSets) to be created in the hub cluster or not.
5454
// If set to false, the KubeFleet pod and replicaset validating webhooks, which blocks the creation
55-
// of pods and replicaSets outside KubeFleet reserved namespaces for most users, will be disabled.
55+
// of pods and replicaSets outside KubeFleet reserved namespaces for most users, will be enabled.
5656
// This option only applies if webhooks are enabled.
5757
EnableWorkload bool
5858

59+
// Enable PodDisruptionBudgets to be created in the hub cluster or not. If set to false, the KubeFleet
60+
// PodDisruptionBudget validating webhook, which blocks the creation of PodDisruptionBudgets outside KubeFleet
61+
// reserved namespaces, will be enabled. This option only applies if webhooks are enabled.
62+
EnablePDBs bool
63+
5964
// Use the cert-manager project for managing KubeFleet webhook server certificates or not.
6065
// If set to false, the system will use self-signed certificates.
6166
// This option only applies if webhooks are enabled.
@@ -109,7 +114,14 @@ func (o *WebhookOptions) AddFlags(flags *flag.FlagSet) {
109114
&o.EnableWorkload,
110115
"enable-workload",
111116
false,
112-
"Enable workload resources (pods and replicaSets) to be created in the hub cluster or not. If set to false, the KubeFleet pod and replicaset validating webhooks, which blocks the creation of pods and replicaSets outside KubeFleet reserved namespaces for most users, will be disabled. This option only applies if webhooks are enabled.",
117+
"Enable workload resources (pods and replicaSets) to be created directly in the hub cluster or not. If set to true, the KubeFleet pod and replicaset validating webhooks, which blocks the creation of pods and replicaSets outside KubeFleet reserved namespaces for most users, will be disabled. This option only applies if webhooks are enabled.",
118+
)
119+
120+
flags.BoolVar(
121+
&o.EnablePDBs,
122+
"enable-pdbs",
123+
false,
124+
"Enable PodDisruptionBudgets to be created directly in the hub cluster or not. If set to true, the KubeFleet PodDisruptionBudget validating webhook, which blocks the creation of PodDisruptionBudgets outside KubeFleet reserved namespaces, will be disabled. This option only applies if webhooks are enabled.",
113125
)
114126

115127
flags.BoolVar(

pkg/webhook/add_handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/kubefleet-dev/kubefleet/pkg/webhook/clusterresourceplacementeviction"
88
"github.com/kubefleet-dev/kubefleet/pkg/webhook/fleetresourcehandler"
99
"github.com/kubefleet-dev/kubefleet/pkg/webhook/membercluster"
10+
"github.com/kubefleet-dev/kubefleet/pkg/webhook/pdb"
1011
"github.com/kubefleet-dev/kubefleet/pkg/webhook/pod"
1112
"github.com/kubefleet-dev/kubefleet/pkg/webhook/replicaset"
1213
"github.com/kubefleet-dev/kubefleet/pkg/webhook/resourceoverride"
@@ -23,6 +24,7 @@ func init() {
2324
AddToManagerFuncs = append(AddToManagerFuncs, resourceplacement.Add)
2425
AddToManagerFuncs = append(AddToManagerFuncs, pod.Add)
2526
AddToManagerFuncs = append(AddToManagerFuncs, replicaset.Add)
27+
AddToManagerFuncs = append(AddToManagerFuncs, pdb.Add)
2628
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceoverride.Add)
2729
AddToManagerFuncs = append(AddToManagerFuncs, resourceoverride.Add)
2830
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacementeviction.Add)

pkg/webhook/clusterresourceoverride/clusterresourceoverride_validating_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type clusterResourceOverrideValidator struct {
4646
decoder webhook.AdmissionDecoder
4747
}
4848

49-
// Add registers the webhook for K8s bulit-in object types.
49+
// Add registers the webhook for K8s built-in object types.
5050
func Add(mgr manager.Manager) error {
5151
hookServer := mgr.GetWebhookServer()
5252
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &clusterResourceOverrideValidator{mgr.GetAPIReader(), admission.NewDecoder(mgr.GetScheme())}})

pkg/webhook/clusterresourceplacement/v1beta1_clusterresourceplacement_validating_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type clusterResourcePlacementValidator struct {
3939
decoder webhook.AdmissionDecoder
4040
}
4141

42-
// Add registers the webhook for K8s bulit-in object types.
42+
// Add registers the webhook for K8s built-in object types.
4343
func Add(mgr manager.Manager) error {
4444
hookServer := mgr.GetWebhookServer()
4545
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &clusterResourcePlacementValidator{admission.NewDecoder(mgr.GetScheme())}})

pkg/webhook/clusterresourceplacementdisruptionbudget/clusterresourceplacementdisruptionbudget_validating_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ type clusterResourcePlacementDisruptionBudgetValidator struct {
4242
decoder webhook.AdmissionDecoder
4343
}
4444

45-
// Add registers the webhook for K8s bulit-in object types.
45+
// Add registers the webhook for K8s built-in object types.
4646
func Add(mgr manager.Manager) error {
4747
hookServer := mgr.GetWebhookServer()
4848
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &clusterResourcePlacementDisruptionBudgetValidator{mgr.GetClient(), admission.NewDecoder(mgr.GetScheme())}})

pkg/webhook/clusterresourceplacementeviction/clusterresourceplacementeviction_validating_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type clusterResourcePlacementEvictionValidator struct {
4646
decoder webhook.AdmissionDecoder
4747
}
4848

49-
// Add registers the webhook for K8s bulit-in object types.
49+
// Add registers the webhook for K8s built-in object types.
5050
func Add(mgr manager.Manager) error {
5151
hookServer := mgr.GetWebhookServer()
5252
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &clusterResourcePlacementEvictionValidator{mgr.GetClient(), admission.NewDecoder(mgr.GetScheme())}})

pkg/webhook/membercluster/membercluster_validating_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ type memberClusterValidator struct {
3131
networkingAgentsEnabled bool
3232
}
3333

34-
// Add registers the webhook for K8s bulit-in object types.
34+
// Add registers the webhook for K8s built-in object types.
3535
func Add(mgr manager.Manager, networkingAgentsEnabled bool) {
3636
hookServer := mgr.GetWebhookServer()
3737
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &memberClusterValidator{
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
Copyright 2026 The KubeFleet 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 pdb
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"net/http"
23+
24+
admissionv1 "k8s.io/api/admission/v1"
25+
policyv1 "k8s.io/api/policy/v1"
26+
"k8s.io/apimachinery/pkg/types"
27+
"k8s.io/klog/v2"
28+
"sigs.k8s.io/controller-runtime/pkg/manager"
29+
"sigs.k8s.io/controller-runtime/pkg/webhook"
30+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
31+
32+
"github.com/kubefleet-dev/kubefleet/pkg/utils"
33+
)
34+
35+
const (
36+
usrFriendlyDenialErrMsgFmt = "PDBs %s/%s cannot be directly created in the hub cluster due to potential side effects; to place a PDB to member clusters, consider wrapping it in a resource envelope."
37+
)
38+
39+
var (
40+
// ValidationPath is the webhook service path which admission requests are routed to for validating PodDisruptionBudget resources.
41+
ValidationPath = fmt.Sprintf(utils.ValidationPathFmt, policyv1.SchemeGroupVersion.Group, policyv1.SchemeGroupVersion.Version, "poddisruptionbudget")
42+
)
43+
44+
// Add registers the webhook for K8s built-in object types.
45+
func Add(mgr manager.Manager) error {
46+
hookServer := mgr.GetWebhookServer()
47+
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &pdbValidator{admission.NewDecoder(mgr.GetScheme())}})
48+
return nil
49+
}
50+
51+
type pdbValidator struct {
52+
decoder webhook.AdmissionDecoder
53+
}
54+
55+
// Handle pdbValidator denies a PodDisruptionBudget if it is not created in the system namespaces.
56+
func (v *pdbValidator) Handle(_ context.Context, req admission.Request) admission.Response {
57+
namespacedName := types.NamespacedName{Name: req.Name, Namespace: req.Namespace}
58+
if req.Operation == admissionv1.Create {
59+
klog.V(2).InfoS("handling PodDisruptionBudget resource", "operation", req.Operation, "subResource", req.SubResource, "namespacedName", namespacedName)
60+
pdb := &policyv1.PodDisruptionBudget{}
61+
if err := v.decoder.Decode(req, pdb); err != nil {
62+
return admission.Errored(http.StatusBadRequest, err)
63+
}
64+
if !utils.IsReservedNamespace(pdb.Namespace) {
65+
klog.V(2).InfoS("Denying creation of PDBs in non-reserved namespaces",
66+
"user", req.UserInfo.Username, "groups", req.UserInfo.Groups,
67+
"operation", req.Operation,
68+
"GVK", req.RequestKind,
69+
"subResource", req.SubResource, "namespacedName", namespacedName)
70+
return admission.Denied(fmt.Sprintf(usrFriendlyDenialErrMsgFmt, pdb.Namespace, pdb.Name))
71+
}
72+
}
73+
klog.V(3).InfoS("Allowing operations on PDBs",
74+
"user", req.UserInfo.Username, "groups", req.UserInfo.Groups,
75+
"operation", req.Operation,
76+
"GVK", req.RequestKind,
77+
"subResource", req.SubResource, "namespacedName", namespacedName)
78+
return admission.Allowed("")
79+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
Copyright 2026 The KubeFleet 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 pdb
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"net/http"
25+
"testing"
26+
27+
"github.com/google/go-cmp/cmp"
28+
"github.com/google/go-cmp/cmp/cmpopts"
29+
admissionv1 "k8s.io/api/admission/v1"
30+
authenticationv1 "k8s.io/api/authentication/v1"
31+
policyv1 "k8s.io/api/policy/v1"
32+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
"k8s.io/apimachinery/pkg/runtime"
34+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
35+
)
36+
37+
func TestHandle(t *testing.T) {
38+
scheme := runtime.NewScheme()
39+
if err := policyv1.AddToScheme(scheme); err != nil {
40+
t.Fatalf("policyv1.AddToScheme() = %v, want nil", err)
41+
}
42+
decoder := admission.NewDecoder(scheme)
43+
44+
pdbInDefaultNS := &policyv1.PodDisruptionBudget{
45+
ObjectMeta: metav1.ObjectMeta{
46+
Name: "test-pdb",
47+
Namespace: "default",
48+
},
49+
}
50+
pdbInFleetNS := &policyv1.PodDisruptionBudget{
51+
ObjectMeta: metav1.ObjectMeta{
52+
Name: "test-pdb",
53+
Namespace: "fleet-system",
54+
},
55+
}
56+
pdbInKubeNS := &policyv1.PodDisruptionBudget{
57+
ObjectMeta: metav1.ObjectMeta{
58+
Name: "test-pdb",
59+
Namespace: "kube-system",
60+
},
61+
}
62+
63+
pdbInDefaultNSBytes, err := json.Marshal(pdbInDefaultNS)
64+
if err != nil {
65+
t.Fatalf("json.Marshal() = %v, want nil", err)
66+
}
67+
pdbInFleetNSBytes, err := json.Marshal(pdbInFleetNS)
68+
if err != nil {
69+
t.Fatalf("json.Marshal() = %v, want nil", err)
70+
}
71+
pdbInKubeNSBytes, err := json.Marshal(pdbInKubeNS)
72+
if err != nil {
73+
t.Fatalf("json.Marshal() = %v, want nil", err)
74+
}
75+
76+
userInfo := authenticationv1.UserInfo{
77+
Username: "test-user",
78+
Groups: []string{"system:authenticated"},
79+
}
80+
81+
testCases := map[string]struct {
82+
req admission.Request
83+
wantResponse admission.Response
84+
}{
85+
"deny CREATE in non-reserved namespace": {
86+
req: admission.Request{
87+
AdmissionRequest: admissionv1.AdmissionRequest{
88+
Name: "test-pdb",
89+
Namespace: "default",
90+
Operation: admissionv1.Create,
91+
Object: runtime.RawExtension{
92+
Raw: pdbInDefaultNSBytes,
93+
Object: pdbInDefaultNS,
94+
},
95+
UserInfo: userInfo,
96+
},
97+
},
98+
wantResponse: admission.Denied(fmt.Sprintf(usrFriendlyDenialErrMsgFmt, "default", "test-pdb")),
99+
},
100+
"allow CREATE in fleet- reserved namespace": {
101+
req: admission.Request{
102+
AdmissionRequest: admissionv1.AdmissionRequest{
103+
Name: "test-pdb",
104+
Namespace: "fleet-system",
105+
Operation: admissionv1.Create,
106+
Object: runtime.RawExtension{
107+
Raw: pdbInFleetNSBytes,
108+
Object: pdbInFleetNS,
109+
},
110+
UserInfo: userInfo,
111+
},
112+
},
113+
wantResponse: admission.Allowed(""),
114+
},
115+
"allow CREATE in kube- reserved namespace": {
116+
req: admission.Request{
117+
AdmissionRequest: admissionv1.AdmissionRequest{
118+
Name: "test-pdb",
119+
Namespace: "kube-system",
120+
Operation: admissionv1.Create,
121+
Object: runtime.RawExtension{
122+
Raw: pdbInKubeNSBytes,
123+
Object: pdbInKubeNS,
124+
},
125+
UserInfo: userInfo,
126+
},
127+
},
128+
wantResponse: admission.Allowed(""),
129+
},
130+
"allow UPDATE (non-CREATE operation)": {
131+
req: admission.Request{
132+
AdmissionRequest: admissionv1.AdmissionRequest{
133+
Name: "test-pdb",
134+
Namespace: "default",
135+
Operation: admissionv1.Update,
136+
UserInfo: userInfo,
137+
},
138+
},
139+
wantResponse: admission.Allowed(""),
140+
},
141+
"allow DELETE (non-CREATE operation)": {
142+
req: admission.Request{
143+
AdmissionRequest: admissionv1.AdmissionRequest{
144+
Name: "test-pdb",
145+
Namespace: "default",
146+
Operation: admissionv1.Delete,
147+
UserInfo: userInfo,
148+
},
149+
},
150+
wantResponse: admission.Allowed(""),
151+
},
152+
"error on malformed request object": {
153+
req: admission.Request{
154+
AdmissionRequest: admissionv1.AdmissionRequest{
155+
Name: "test-pdb",
156+
Namespace: "default",
157+
Operation: admissionv1.Create,
158+
Object: runtime.RawExtension{
159+
Raw: []byte("not valid json"),
160+
},
161+
UserInfo: userInfo,
162+
},
163+
},
164+
// The exact error message from the decoder is implementation-defined;
165+
// only the status code and allowed=false are compared.
166+
wantResponse: admission.Errored(http.StatusBadRequest, errors.New("")),
167+
},
168+
}
169+
170+
for testName, testCase := range testCases {
171+
t.Run(testName, func(t *testing.T) {
172+
v := pdbValidator{decoder: decoder}
173+
gotResponse := v.Handle(context.Background(), testCase.req)
174+
if diff := cmp.Diff(gotResponse, testCase.wantResponse, cmpopts.IgnoreFields(metav1.Status{}, "Message")); diff != "" {
175+
t.Errorf("Handle() mismatch (-got +want):\n%s", diff)
176+
}
177+
})
178+
}
179+
}

pkg/webhook/pod/pod_validating_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ var (
4343
ValidationPath = fmt.Sprintf(utils.ValidationPathFmt, corev1.SchemeGroupVersion.Group, corev1.SchemeGroupVersion.Version, "pod")
4444
)
4545

46-
// Add registers the webhook for K8s bulit-in object types.
46+
// Add registers the webhook for K8s built-in object types.
4747
func Add(mgr manager.Manager) error {
4848
hookServer := mgr.GetWebhookServer()
4949
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &podValidator{admission.NewDecoder(mgr.GetScheme())}})

0 commit comments

Comments
 (0)