diff --git a/api/v1alpha1/ippool_test.go b/api/v1alpha1/ippool_test.go new file mode 100644 index 0000000..1523a70 --- /dev/null +++ b/api/v1alpha1/ippool_test.go @@ -0,0 +1,128 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package v1alpha1_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/Mellanox/nvidia-k8s-ipam/api/v1alpha1" +) + +var _ = Describe("Validate", func() { + It("Valid", func() { + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: "192.168.0.0/16", + PerNodeBlockSize: 128, + Gateway: "192.168.0.1", + NodeSelector: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "foo.bar", + Operator: corev1.NodeSelectorOpExists, + }}, + }}, + }, + }, + } + Expect(ipPool.Validate()).To(BeEmpty()) + }) + It("Valid - ipv6", func() { + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: "2001:db8:3333:4444::0/64", + PerNodeBlockSize: 1000, + Gateway: "2001:db8:3333:4444::1", + }, + } + Expect(ipPool.Validate()).To(BeEmpty()) + }) + It("Valid - no NodeSelector", func() { + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: "192.168.0.0/16", + PerNodeBlockSize: 128, + Gateway: "192.168.0.1", + }, + } + Expect(ipPool.Validate()).To(BeEmpty()) + }) + It("Empty object", func() { + ipPool := v1alpha1.IPPool{} + Expect(ipPool.Validate().ToAggregate().Error()). + To(And( + ContainSubstring("metadata.name"), + ContainSubstring("spec.subnet"), + ContainSubstring("spec.perNodeBlockSize"), + ContainSubstring("gateway"), + )) + }) + It("Invalid - perNodeBlockSize is too large", func() { + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: "192.168.0.0/24", + PerNodeBlockSize: 300, + Gateway: "192.168.0.1", + }, + } + Expect(ipPool.Validate().ToAggregate().Error()). + To( + ContainSubstring("spec.perNodeBlockSize"), + ) + }) + It("Invalid - gateway outside of the subnet", func() { + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: "192.168.0.0/16", + PerNodeBlockSize: 128, + Gateway: "10.0.0.1", + }, + } + Expect(ipPool.Validate().ToAggregate().Error()). + To( + ContainSubstring("spec.gateway"), + ) + }) + It("Invalid - invalid NodeSelector", func() { + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: "192.168.0.0/16", + PerNodeBlockSize: 128, + Gateway: "192.168.0.1", + NodeSelector: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "foo.bar", + Operator: "unknown", + }}, + }}, + }, + }, + } + Expect(ipPool.Validate().ToAggregate().Error()). + To( + ContainSubstring("spec.nodeSelector"), + ) + }) +}) diff --git a/api/v1alpha1/ippool_validate.go b/api/v1alpha1/ippool_validate.go new file mode 100644 index 0000000..f4d7daa --- /dev/null +++ b/api/v1alpha1/ippool_validate.go @@ -0,0 +1,72 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package v1alpha1 + +import ( + "math" + "net" + + cniUtils "github.com/containernetworking/cni/pkg/utils" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// Validate contains validation for the object fields +func (r *IPPool) Validate() field.ErrorList { + errList := field.ErrorList{} + if err := cniUtils.ValidateNetworkName(r.Name); err != nil { + errList = append(errList, field.Invalid( + field.NewPath("metadata", "name"), r.Name, + "invalid IP pool name, should be compatible with CNI network name")) + } + _, network, err := net.ParseCIDR(r.Spec.Subnet) + if err != nil { + errList = append(errList, field.Invalid( + field.NewPath("spec", "subnet"), r.Spec.Subnet, "is invalid subnet")) + } + + if r.Spec.PerNodeBlockSize < 2 { + errList = append(errList, field.Invalid( + field.NewPath("spec", "perNodeBlockSize"), + r.Spec.PerNodeBlockSize, "must be at least 2")) + } + + if network != nil && r.Spec.PerNodeBlockSize >= 2 { + setBits, bitsTotal := network.Mask.Size() + // possibleIPs = net size - network address - broadcast + possibleIPs := int(math.Pow(2, float64(bitsTotal-setBits))) - 2 + if possibleIPs < r.Spec.PerNodeBlockSize { + // config is not valid even if only one node exist in the cluster + errList = append(errList, field.Invalid( + field.NewPath("spec", "perNodeBlockSize"), r.Spec.PerNodeBlockSize, + "is larger then amount of IPs available in the subnet")) + } + } + parsedGW := net.ParseIP(r.Spec.Gateway) + if len(parsedGW) == 0 { + errList = append(errList, field.Invalid( + field.NewPath("spec", "gateway"), r.Spec.Gateway, + "is invalid IP address")) + } + + if network != nil && len(parsedGW) != 0 && !network.Contains(parsedGW) { + errList = append(errList, field.Invalid( + field.NewPath("spec", "gateway"), r.Spec.Gateway, + "is not part of the subnet")) + } + + if r.Spec.NodeSelector != nil { + errList = append(errList, validateNodeSelector(r.Spec.NodeSelector, field.NewPath("spec"))...) + } + return errList +} diff --git a/api/v1alpha1/ippool_webhook.go b/api/v1alpha1/ippool_webhook.go new file mode 100644 index 0000000..9910f08 --- /dev/null +++ b/api/v1alpha1/ippool_webhook.go @@ -0,0 +1,60 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logPkg "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +var logger = logPkg.Log.WithName("IPPool-validator") + +// SetupWebhookWithManager registers webhook handler in the manager +func (r *IPPool) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +var _ webhook.Validator = &IPPool{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *IPPool) ValidateCreate() error { + logger.V(1).Info("validate create", "name", r.Name) + return r.validate() +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *IPPool) ValidateUpdate(_ runtime.Object) error { + logger.V(1).Info("validate update", "name", r.Name) + return r.validate() +} + +func (r *IPPool) validate() error { + errList := r.Validate() + if len(errList) == 0 { + logger.V(1).Info("validation succeed") + return nil + } + err := errList.ToAggregate() + logger.V(1).Info("validation failed", "reason", err.Error()) + return err +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *IPPool) ValidateDelete() error { + return nil +} diff --git a/api/v1alpha1/v1alpha1_suite_test.go b/api/v1alpha1/v1alpha1_suite_test.go new file mode 100644 index 0000000..761ff02 --- /dev/null +++ b/api/v1alpha1/v1alpha1_suite_test.go @@ -0,0 +1,13 @@ +package v1alpha1_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestV1alpha1(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "V1alpha1 Suite") +} diff --git a/api/v1alpha1/validate_nodeselector.go b/api/v1alpha1/validate_nodeselector.go new file mode 100644 index 0000000..2341e9e --- /dev/null +++ b/api/v1alpha1/validate_nodeselector.go @@ -0,0 +1,123 @@ +/* + Copyright 2023, NVIDIA CORPORATION & AFFILIATES + Copyright 2014 The Kubernetes Authors. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// this package contains logic to validate NodeSelector field +// functions are copied from kubernetes/pkg/apis/core/validation to avoid import of the +// main k8s project + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + apimachineryValidation "k8s.io/apimachinery/pkg/api/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metaValidation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// validateNodeSelector tests that the specified nodeSelector fields has valid data +func validateNodeSelector(nodeSelector *corev1.NodeSelector, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + termFldPath := fldPath.Child("nodeSelectorTerms") + if len(nodeSelector.NodeSelectorTerms) == 0 { + return append(allErrs, field.Required(termFldPath, "must have at least one node selector term")) + } + + for i, term := range nodeSelector.NodeSelectorTerms { + allErrs = append(allErrs, validateNodeSelectorTerm(term, termFldPath.Index(i))...) + } + + return allErrs +} + +// validateNodeSelectorTerm tests that the specified node selector term has valid data +func validateNodeSelectorTerm(term corev1.NodeSelectorTerm, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + for j, req := range term.MatchExpressions { + allErrs = append(allErrs, validateNodeSelectorRequirement(req, + fldPath.Child("matchExpressions").Index(j))...) + } + + for j, req := range term.MatchFields { + allErrs = append(allErrs, validateNodeFieldSelectorRequirement(req, + fldPath.Child("matchFields").Index(j))...) + } + + return allErrs +} + +// validateNodeSelectorRequirement tests that the specified NodeSelectorRequirement fields has valid data +func validateNodeSelectorRequirement(rq corev1.NodeSelectorRequirement, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + switch rq.Operator { + case corev1.NodeSelectorOpIn, corev1.NodeSelectorOpNotIn: + if len(rq.Values) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("values"), + "must be specified when `operator` is 'In' or 'NotIn'")) + } + case corev1.NodeSelectorOpExists, corev1.NodeSelectorOpDoesNotExist: + if len(rq.Values) > 0 { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("values"), + "may not be specified when `operator` is 'Exists' or 'DoesNotExist'")) + } + + case corev1.NodeSelectorOpGt, corev1.NodeSelectorOpLt: + if len(rq.Values) != 1 { + allErrs = append(allErrs, field.Required(fldPath.Child("values"), + "must be specified single value when `operator` is 'Lt' or 'Gt'")) + } + default: + allErrs = append(allErrs, field.Invalid(fldPath.Child("operator"), rq.Operator, + "not a valid selector operator")) + } + + allErrs = append(allErrs, metaValidation.ValidateLabelName(rq.Key, fldPath.Child("key"))...) + + return allErrs +} + +var nodeFieldSelectorValidators = map[string]func(string, bool) []string{ + metav1.ObjectNameField: apimachineryValidation.NameIsDNSSubdomain, +} + +// validateNodeFieldSelectorRequirement tests that the specified NodeSelectorRequirement fields has valid data +func validateNodeFieldSelectorRequirement(req corev1.NodeSelectorRequirement, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + switch req.Operator { + case corev1.NodeSelectorOpIn, corev1.NodeSelectorOpNotIn: + if len(req.Values) != 1 { + allErrs = append(allErrs, field.Required(fldPath.Child("values"), + "must be only one value when `operator` is 'In' or 'NotIn' for node field selector")) + } + default: + allErrs = append(allErrs, field.Invalid(fldPath.Child("operator"), req.Operator, + "not a valid selector operator")) + } + + if vf, found := nodeFieldSelectorValidators[req.Key]; !found { + allErrs = append(allErrs, field.Invalid(fldPath.Child("key"), req.Key, + "not a valid field selector key")) + } else { + for i, v := range req.Values { + for _, msg := range vf(v, false) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("values").Index(i), v, msg)) + } + } + } + + return allErrs +} diff --git a/cmd/ipam-controller/app/app.go b/cmd/ipam-controller/app/app.go index 70fe4db..f3a16b6 100644 --- a/cmd/ipam-controller/app/app.go +++ b/cmd/ipam-controller/app/app.go @@ -162,6 +162,13 @@ func RunController(ctx context.Context, config *rest.Config, opts *options.Optio return err } + if opts.EnableWebHook { + if err = (&ipamv1alpha1.IPPool{}).SetupWebhookWithManager(mgr); err != nil { + logger.Error(err, "unable to create webhook", "webhook", "IPPool") + os.Exit(1) + } + } + if err = (&poolctrl.IPPoolReconciler{ NodeEventCh: nodeEventCH, PoolsNamespace: opts.IPPoolsNamespace, diff --git a/cmd/ipam-controller/app/options/options.go b/cmd/ipam-controller/app/options/options.go index d209b50..1a29c9f 100644 --- a/cmd/ipam-controller/app/options/options.go +++ b/cmd/ipam-controller/app/options/options.go @@ -29,6 +29,7 @@ func New() *Options { MetricsAddr: ":8080", ProbeAddr: ":8081", EnableLeaderElection: false, + EnableWebHook: false, LeaderElectionNamespace: "kube-system", IPPoolsNamespace: "kube-system", } @@ -40,6 +41,7 @@ type Options struct { MetricsAddr string ProbeAddr string EnableLeaderElection bool + EnableWebHook bool LeaderElectionNamespace string IPPoolsNamespace string } @@ -58,6 +60,8 @@ func (o *Options) AddNamedFlagSets(sharedFS *cliflag.NamedFlagSets) { "The address the metric endpoint binds to.") controllerFS.StringVar(&o.ProbeAddr, "health-probe-bind-address", o.ProbeAddr, "The address the probe endpoint binds to.") + controllerFS.BoolVar(&o.EnableWebHook, "webhook", o.EnableWebHook, + "Enable validating webhook server as a part of the controller") controllerFS.BoolVar(&o.EnableLeaderElection, "leader-elect", o.EnableLeaderElection, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") diff --git a/pkg/ipam-controller/config/config.go b/pkg/ipam-controller/config/config.go index e4c78cc..3ed6356 100644 --- a/pkg/ipam-controller/config/config.go +++ b/pkg/ipam-controller/config/config.go @@ -15,12 +15,12 @@ package config import ( "fmt" - "math" - "net" - cniUtils "github.com/containernetworking/cni/pkg/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metaValidation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" validationField "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/Mellanox/nvidia-k8s-ipam/api/v1alpha1" ) const ( @@ -65,33 +65,14 @@ func (c *Config) Validate() error { // ValidatePool validates the IPPool parameters func ValidatePool(name string, subnet string, gateway string, blockSize int) error { - if err := cniUtils.ValidateNetworkName(name); err != nil { - return fmt.Errorf("invalid IP pool name %s, should be compatible with CNI network name", name) - } - _, network, err := net.ParseCIDR(subnet) - if err != nil { - return fmt.Errorf("IP pool %s contains invalid subnet: %v", name, err) + ipPool := v1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: v1alpha1.IPPoolSpec{ + Subnet: subnet, + PerNodeBlockSize: blockSize, + Gateway: gateway, + NodeSelector: nil, + }, } - - if blockSize < 2 { - return fmt.Errorf("perNodeBlockSize should be at least 2") - } - - setBits, bitsTotal := network.Mask.Size() - // possibleIPs = net size - network address - broadcast - possibleIPs := int(math.Pow(2, float64(bitsTotal-setBits))) - 2 - if possibleIPs < blockSize { - // config is not valid even if only one node exist in the cluster - return fmt.Errorf("IP pool subnet contains less available IPs then " + - "requested by perNodeBlockSize parameter") - } - parsedGW := net.ParseIP(gateway) - if len(parsedGW) == 0 { - return fmt.Errorf("IP pool contains invalid gateway configuration: invalid IP") - } - if !network.Contains(parsedGW) { - return fmt.Errorf("IP pool contains invalid gateway configuration: " + - "gateway is outside of the subnet") - } - return nil + return ipPool.Validate().ToAggregate() } diff --git a/pkg/ipam-controller/controllers/ippool/ippool.go b/pkg/ipam-controller/controllers/ippool/ippool.go index 9221ba0..f540ea9 100644 --- a/pkg/ipam-controller/controllers/ippool/ippool.go +++ b/pkg/ipam-controller/controllers/ippool/ippool.go @@ -39,7 +39,6 @@ import ( ipamv1alpha1 "github.com/Mellanox/nvidia-k8s-ipam/api/v1alpha1" "github.com/Mellanox/nvidia-k8s-ipam/pkg/ipam-controller/allocator" - "github.com/Mellanox/nvidia-k8s-ipam/pkg/ipam-controller/config" ) // IPPoolReconciler reconciles Pool objects @@ -78,9 +77,9 @@ func (r *IPPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr } reqLog.Info("Notification on IPPool", "name", pool.Name) - err = config.ValidatePool(pool.Name, pool.Spec.Subnet, pool.Spec.Gateway, pool.Spec.PerNodeBlockSize) - if err != nil { - return r.handleInvalidSpec(ctx, err, pool) + errList := pool.Validate() + if len(errList) != 0 { + return r.handleInvalidSpec(ctx, errList.ToAggregate(), pool) } nodeList := &corev1.NodeList{}