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

Commit cec30f5

Browse files
authored
Merge pull request #132 from erikgb/refactor/deny-invalid
Improve webhooks deny invalid fields
2 parents 550d0c8 + 485aef1 commit cec30f5

File tree

4 files changed

+63
-120
lines changed

4 files changed

+63
-120
lines changed

internal/anchor/validator.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import (
77

88
"github.com/go-logr/logr"
99
k8sadm "k8s.io/api/admission/v1"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
1011
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
1113
"k8s.io/apimachinery/pkg/util/validation"
14+
"k8s.io/apimachinery/pkg/util/validation/field"
1215
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
1316

1417
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
@@ -81,8 +84,15 @@ func (v *Validator) handle(req *anchorRequest) admission.Response {
8184

8285
errStrs := validation.IsDNS1123Label(cnm)
8386
if len(errStrs) != 0 {
84-
msg := fmt.Sprintf("Subnamespace %s is not a valid namespace name: %s", cnm, strings.Join(errStrs, "; "))
85-
return webhooks.Deny(metav1.StatusReasonInvalid, msg)
87+
fldPath := field.NewPath("metadata", "name")
88+
msg := fmt.Sprintf("not a valid namespace name: %s", strings.Join(errStrs, "; "))
89+
allErrs := field.ErrorList{
90+
field.Invalid(fldPath, cnm, msg),
91+
}
92+
93+
gk := schema.GroupKind{Group: api.GroupVersion.Group, Kind: "SubnamespaceAnchor"}
94+
err := apierrors.NewInvalid(gk, cnm, allErrs)
95+
return webhooks.DenyFromAPIError(err)
8696
}
8797

8898
switch req.op {

internal/hierarchyconfig/validator.go

Lines changed: 15 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ import (
1515
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1616
"k8s.io/apimachinery/pkg/runtime/schema"
1717
"k8s.io/apimachinery/pkg/types"
18-
"k8s.io/apimachinery/pkg/util/validation/field"
1918
"sigs.k8s.io/controller-runtime/pkg/client"
2019
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2120

2221
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
2322
"sigs.k8s.io/hierarchical-namespaces/internal/config"
2423
"sigs.k8s.io/hierarchical-namespaces/internal/forest"
2524
"sigs.k8s.io/hierarchical-namespaces/internal/selectors"
25+
"sigs.k8s.io/hierarchical-namespaces/internal/webhooks"
2626
)
2727

2828
const (
@@ -89,7 +89,7 @@ func (v *Validator) Handle(ctx context.Context, req admission.Request) admission
8989
decoded, err := v.decodeRequest(req)
9090
if err != nil {
9191
log.Error(err, "Couldn't decode request")
92-
return deny(metav1.StatusReasonBadRequest, err.Error())
92+
return webhooks.Deny(metav1.StatusReasonBadRequest, err.Error())
9393
}
9494

9595
resp := v.handle(ctx, log, decoded)
@@ -122,11 +122,11 @@ func (v *Validator) handle(ctx context.Context, log logr.Logger, req *request) a
122122

123123
if why := config.WhyUnmanaged(req.hc.Namespace); why != "" {
124124
reason := fmt.Sprintf("Namespace %q is not managed by HNC (%s) and cannot be set as a child of another namespace", req.hc.Namespace, why)
125-
return deny(metav1.StatusReasonForbidden, reason)
125+
return webhooks.Deny(metav1.StatusReasonForbidden, reason)
126126
}
127127
if why := config.WhyUnmanaged(req.hc.Spec.Parent); why != "" {
128128
reason := fmt.Sprintf("Namespace %q is not managed by HNC (%s) and cannot be set as the parent of another namespace", req.hc.Spec.Parent, why)
129-
return deny(metav1.StatusReasonForbidden, reason)
129+
return webhooks.Deny(metav1.StatusReasonForbidden, reason)
130130
}
131131

132132
// Do all checks that require holding the in-memory lock. Generate a list of server checks we
@@ -172,15 +172,15 @@ func (v *Validator) checkNS(ns *forest.Namespace) admission.Response {
172172
// Wait until the namespace has been synced
173173
if !ns.Exists() {
174174
msg := fmt.Sprintf("HNC has not reconciled namespace %q yet - please try again in a few moments.", ns.Name())
175-
return deny(metav1.StatusReasonServiceUnavailable, msg)
175+
return webhooks.Deny(metav1.StatusReasonServiceUnavailable, msg)
176176
}
177177

178178
// Deny the request if the namespace has a halted root - but not if it's halted itself, since we
179179
// may be trying to resolve the halted condition.
180180
haltedRoot := ns.GetHaltedRoot()
181181
if haltedRoot != "" && haltedRoot != ns.Name() {
182182
msg := fmt.Sprintf("The ancestor %q of namespace %q has a critical condition, which must be resolved before any changes can be made to the hierarchy configuration.", haltedRoot, ns.Name())
183-
return deny(metav1.StatusReasonForbidden, msg)
183+
return webhooks.Deny(metav1.StatusReasonForbidden, msg)
184184
}
185185

186186
return allow("")
@@ -190,7 +190,7 @@ func (v *Validator) checkNS(ns *forest.Namespace) admission.Response {
190190
func (v *Validator) checkParent(ns, curParent, newParent *forest.Namespace) admission.Response {
191191
if ns.IsExternal() && newParent != nil {
192192
msg := fmt.Sprintf("Namespace %q is managed by %q, not HNC, so it cannot have a parent in HNC.", ns.Name(), ns.Manager)
193-
return deny(metav1.StatusReasonForbidden, msg)
193+
return webhooks.Deny(metav1.StatusReasonForbidden, msg)
194194
}
195195

196196
if curParent == newParent {
@@ -200,12 +200,12 @@ func (v *Validator) checkParent(ns, curParent, newParent *forest.Namespace) admi
200200
// Prevent changing parent of a subnamespace
201201
if ns.IsSub {
202202
reason := fmt.Sprintf("Cannot set the parent of %q to %q because it's a subnamespace of %q", ns.Name(), newParent.Name(), curParent.Name())
203-
return deny(metav1.StatusReasonConflict, "Illegal parent: "+reason)
203+
return webhooks.Deny(metav1.StatusReasonConflict, "Illegal parent: "+reason)
204204
}
205205

206206
// non existence of parent namespace -> not allowed
207207
if newParent != nil && !newParent.Exists() {
208-
return deny(metav1.StatusReasonForbidden, "The requested parent "+newParent.Name()+" does not exist")
208+
return webhooks.Deny(metav1.StatusReasonForbidden, "The requested parent "+newParent.Name()+" does not exist")
209209
}
210210

211211
// Is this change structurally legal? Note that this can "leak" information about the hierarchy
@@ -214,15 +214,15 @@ func (v *Validator) checkParent(ns, curParent, newParent *forest.Namespace) admi
214214
// have visibility into its ancestry and descendents, and this check can only fail if the new
215215
// parent conflicts with something in the _existing_ hierarchy.
216216
if reason := ns.CanSetParent(newParent); reason != "" {
217-
return deny(metav1.StatusReasonConflict, "Illegal parent: "+reason)
217+
return webhooks.Deny(metav1.StatusReasonConflict, "Illegal parent: "+reason)
218218
}
219219

220220
// Prevent overwriting source objects in the descendants after the hierarchy change.
221221
if co := v.getConflictingObjects(newParent, ns); len(co) != 0 {
222222
msg := "Cannot update hierarchy because it would overwrite the following object(s):\n"
223223
msg += " * " + strings.Join(co, "\n * ") + "\n"
224224
msg += "To fix this, please rename or remove the conflicting objects first."
225-
return deny(metav1.StatusReasonConflict, msg)
225+
return webhooks.Deny(metav1.StatusReasonConflict, msg)
226226
}
227227

228228
return allow("")
@@ -394,23 +394,23 @@ func (v *Validator) checkServer(ctx context.Context, log logr.Logger, ui *authnv
394394
log.Info("Checking existance", "object", req.nnm, "reason", req.reason)
395395
exists, err := v.server.Exists(ctx, req.nnm)
396396
if err != nil {
397-
return deny(metav1.StatusReasonUnknown, fmt.Sprintf("while checking existance for %q, the %s: %s", req.nnm, req.reason, err))
397+
return webhooks.Deny(metav1.StatusReasonUnknown, fmt.Sprintf("while checking existance for %q, the %s: %s", req.nnm, req.reason, err))
398398
}
399399

400400
if exists {
401401
msg := fmt.Sprintf("HNC has not reconciled namespace %q yet - please try again in a few moments.", req.nnm)
402-
return deny(metav1.StatusReasonServiceUnavailable, msg)
402+
return webhooks.Deny(metav1.StatusReasonServiceUnavailable, msg)
403403
}
404404

405405
case checkAuthz:
406406
log.Info("Checking authz", "object", req.nnm, "reason", req.reason)
407407
allowed, err := v.server.IsAdmin(ctx, ui, req.nnm)
408408
if err != nil {
409-
return deny(metav1.StatusReasonUnknown, fmt.Sprintf("while checking authz for %q, the %s: %s", req.nnm, req.reason, err))
409+
return webhooks.Deny(metav1.StatusReasonUnknown, fmt.Sprintf("while checking authz for %q, the %s: %s", req.nnm, req.reason, err))
410410
}
411411

412412
if !allowed {
413-
return deny(metav1.StatusReasonUnauthorized, fmt.Sprintf("User %s is not authorized to modify the subtree of %s, which is the %s",
413+
return webhooks.Deny(metav1.StatusReasonUnauthorized, fmt.Sprintf("User %s is not authorized to modify the subtree of %s, which is the %s",
414414
ui.Username, req.nnm, req.reason))
415415
}
416416
}
@@ -525,69 +525,3 @@ func allow(msg string) admission.Response {
525525
},
526526
}}
527527
}
528-
529-
// deny is a replacement for controller-runtime's admission.Denied() that allows you to set _both_ a
530-
// human-readable message _and_ a machine-readable reason, and also sets the code correctly instead
531-
// of hardcoding it to 403 Forbidden.
532-
func deny(reason metav1.StatusReason, msg string) admission.Response {
533-
return admission.Response{AdmissionResponse: k8sadm.AdmissionResponse{
534-
Allowed: false,
535-
Result: &metav1.Status{
536-
Code: codeFromReason(reason),
537-
Message: msg,
538-
Reason: reason,
539-
}},
540-
}
541-
}
542-
543-
// denyInvalid is a wrapper for deny with reason metav1.StatusReasonInvalid
544-
func denyInvalid(field *field.Path, msg string) admission.Response {
545-
// We need to set the custom message in both Details and Message fields.
546-
//
547-
// When manipulating the HNC configuration object via kubectl directly, kubectl
548-
// ignores the Message field and displays the Details field if an error is
549-
// StatusReasonInvalid (see implementation here: https://github.com/kubernetes/kubectl/blob/master/pkg/cmd/util/helpers.go#L145-L160).
550-
//
551-
// When manipulating the HNC configuration object via the hns kubectl plugin,
552-
// if an error is StatusReasonInvalid, only the Message field will be displayed. This is because
553-
// the Error method (https://github.com/kubernetes/client-go/blob/cb664d40f84c27bee45c193e4acb0fcd549b0305/rest/request.go#L1273)
554-
// calls FromObject (https://github.com/kubernetes/apimachinery/blob/7e441e0f246a2db6cf1855e4110892d1623a80cf/pkg/api/errors/errors.go#L100),
555-
// which generates a StatusError (https://github.com/kubernetes/apimachinery/blob/7e441e0f246a2db6cf1855e4110892d1623a80cf/pkg/api/errors/errors.go#L35) object.
556-
// *StatusError implements the Error interface using only the Message
557-
// field (https://github.com/kubernetes/apimachinery/blob/7e441e0f246a2db6cf1855e4110892d1623a80cf/pkg/api/errors/errors.go#L49)).
558-
// Therefore, when displaying the error, only the Message field will be available.
559-
resp := deny(metav1.StatusReasonInvalid, msg)
560-
resp.Result.Details = &metav1.StatusDetails{
561-
Causes: []metav1.StatusCause{{
562-
Message: msg,
563-
Field: field.String(),
564-
}},
565-
}
566-
567-
return resp
568-
}
569-
570-
// codeFromReason implements the needed subset of
571-
// https://godoc.org/k8s.io/apimachinery/pkg/apis/meta/v1#StatusReason
572-
func codeFromReason(reason metav1.StatusReason) int32 {
573-
switch reason {
574-
case metav1.StatusReasonUnknown:
575-
return 500
576-
case metav1.StatusReasonUnauthorized:
577-
return 401
578-
case metav1.StatusReasonForbidden:
579-
return 403
580-
case metav1.StatusReasonConflict:
581-
return 409
582-
case metav1.StatusReasonBadRequest:
583-
return 400
584-
case metav1.StatusReasonInvalid:
585-
return 422
586-
case metav1.StatusReasonInternalError:
587-
return 500
588-
case metav1.StatusReasonServiceUnavailable:
589-
return 503
590-
default:
591-
return 500
592-
}
593-
}

internal/hncconfig/validator.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import (
77

88
"github.com/go-logr/logr"
99
k8sadm "k8s.io/api/admission/v1"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
1011
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1112
"k8s.io/apimachinery/pkg/runtime/schema"
1213
"k8s.io/apimachinery/pkg/util/validation/field"
1314
"k8s.io/client-go/rest"
1415
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
15-
"sigs.k8s.io/hierarchical-namespaces/internal/forest"
16-
"sigs.k8s.io/hierarchical-namespaces/internal/selectors"
1716

1817
api "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2"
18+
"sigs.k8s.io/hierarchical-namespaces/internal/forest"
19+
"sigs.k8s.io/hierarchical-namespaces/internal/selectors"
1920
"sigs.k8s.io/hierarchical-namespaces/internal/webhooks"
2021
)
2122

@@ -96,31 +97,38 @@ func (v *Validator) handle(ctx context.Context, inst *api.HNCConfiguration) admi
9697
}
9798

9899
func (v *Validator) validateTypes(inst *api.HNCConfiguration, ts gvkSet) admission.Response {
100+
allErrs := field.ErrorList{}
99101
for i, r := range inst.Spec.Resources {
100102
gr := schema.GroupResource{Group: r.Group, Resource: r.Resource}
101-
field := field.NewPath("spec", "resources").Index(i)
103+
fldPath := field.NewPath("spec", "resources").Index(i)
102104
// Validate the type configured is not an HNC enforced type.
103105
if api.IsEnforcedType(r) {
104-
return webhooks.DenyInvalid(field, fmt.Sprintf("Invalid configuration of %s in the spec, because it's enforced by HNC "+
105-
"with 'Propagate' mode. Please remove it from the spec.", gr))
106+
fldErr := field.Invalid(fldPath, gr, "always uses the 'Propagate' mode and cannot be configured")
107+
allErrs = append(allErrs, fldErr)
106108
}
107109

108110
// Validate the type exists in the apiserver. If yes, convert GR to GVK. We
109111
// use GVK because we will need to checkForest() later to avoid source
110112
// overwriting conflict (forest uses GVK as the key for object reconcilers).
111113
gvk, err := v.translator.gvkForGR(gr)
112114
if err != nil {
113-
return webhooks.DenyInvalid(field,
114-
fmt.Sprintf("Cannot find the %s in the apiserver with error: %s", gr, err.Error()))
115+
fldErr := field.Invalid(fldPath, gr, "unknown resource")
116+
allErrs = append(allErrs, fldErr)
115117
}
116118

117119
// Validate if the configuration of a type already exists. Each type should
118120
// only have one configuration.
119121
if _, exists := ts[gvk]; exists {
120-
return webhooks.DenyInvalid(field, fmt.Sprintf("Duplicate configurations for %s", gr))
122+
fldErr := field.Duplicate(fldPath, gr)
123+
allErrs = append(allErrs, fldErr)
121124
}
122125
ts[gvk] = r.Mode
123126
}
127+
if len(allErrs) > 0 {
128+
gk := schema.GroupKind{Group: api.GroupVersion.Group, Kind: "HNCConfiguration"}
129+
err := apierrors.NewInvalid(gk, api.HNCConfigSingleton, allErrs)
130+
return webhooks.DenyFromAPIError(err)
131+
}
124132
return webhooks.Allow("")
125133
}
126134

internal/webhooks/webhooks.go

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66

77
k8sadm "k8s.io/api/admission/v1"
88
authnv1 "k8s.io/api/authentication/v1"
9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
910
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10-
"k8s.io/apimachinery/pkg/util/validation/field"
1111
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
1212
)
1313

@@ -46,42 +46,33 @@ func Allow(msg string) admission.Response {
4646
// Deny is a replacement for controller-runtime's admission.Denied() that allows you to set _both_ a
4747
// human-readable message _and_ a machine-readable reason, and also sets the code correctly instead
4848
// of hardcoding it to 403 Forbidden.
49+
//
50+
// NOTE: When manipulating the HNC configuration object via kubectl directly, kubectl
51+
// ignores the Message field and displays the Details field if an error is
52+
// StatusReasonInvalid. For this reason DenyFromAPIError is preferred for denying
53+
// a request due to invalid data.
54+
//
55+
// Deprecated: Use DenyFromAPIError instead; please don't add new callers.
4956
func Deny(reason metav1.StatusReason, msg string) admission.Response {
50-
return admission.Response{AdmissionResponse: k8sadm.AdmissionResponse{
51-
Allowed: false,
52-
Result: &metav1.Status{
57+
err := &apierrors.StatusError{
58+
ErrStatus: metav1.Status{
5359
Code: CodeFromReason(reason),
5460
Message: msg,
5561
Reason: reason,
56-
}},
62+
},
5763
}
64+
return DenyFromAPIError(err)
5865
}
5966

60-
// DenyInvalid is a wrapper for Deny with reason metav1.StatusReasonInvalid
61-
func DenyInvalid(field *field.Path, msg string) admission.Response {
62-
// We need to set the custom message in both Details and Message fields.
63-
//
64-
// When manipulating the HNC configuration object via kubectl directly, kubectl
65-
// ignores the Message field and displays the Details field if an error is
66-
// StatusReasonInvalid (see implementation here: https://github.com/kubernetes/kubectl/blob/master/pkg/cmd/util/helpers.go#L145-L160).
67-
//
68-
// When manipulating the HNC configuration object via the hns kubectl plugin,
69-
// if an error is StatusReasonInvalid, only the Message field will be displayed. This is because
70-
// the Error method (https://github.com/kubernetes/client-go/blob/cb664d40f84c27bee45c193e4acb0fcd549b0305/rest/request.go#L1273)
71-
// calls FromObject (https://github.com/kubernetes/apimachinery/blob/7e441e0f246a2db6cf1855e4110892d1623a80cf/pkg/api/errors/errors.go#L100),
72-
// which generates a StatusError (https://github.com/kubernetes/apimachinery/blob/7e441e0f246a2db6cf1855e4110892d1623a80cf/pkg/api/errors/errors.go#L35) object.
73-
// *StatusError implements the Error interface using only the Message
74-
// field (https://github.com/kubernetes/apimachinery/blob/7e441e0f246a2db6cf1855e4110892d1623a80cf/pkg/api/errors/errors.go#L49)).
75-
// Therefore, when displaying the error, only the Message field will be available.
76-
resp := Deny(metav1.StatusReasonInvalid, msg)
77-
resp.Result.Details = &metav1.StatusDetails{
78-
Causes: []metav1.StatusCause{{
79-
Message: msg,
80-
Field: field.String(),
81-
}},
67+
// DenyFromAPIError returns a response for denying a request with provided status error object.
68+
func DenyFromAPIError(apiStatus apierrors.APIStatus) admission.Response {
69+
status := apiStatus.Status()
70+
return admission.Response{
71+
AdmissionResponse: k8sadm.AdmissionResponse{
72+
Allowed: false,
73+
Result: &status,
74+
},
8275
}
83-
84-
return resp
8576
}
8677

8778
// CodeFromReason implements the needed subset of

0 commit comments

Comments
 (0)