Skip to content

Commit 87a2c65

Browse files
authored
feat: linodevpc: add validating admission webhook on create (#321)
* fixup! Introduce VPC controller (#62) * feat: linodevpc: kubebuilder create webhook Scaffold a validating admission webhook for the LinodeVPC resource with Kubebuider via the command: kubebuilder create webhook --group infrastructure --version v1alpha1 --kind LinodeVPC --programmatic-validation * fixup! feat: linodevpc: kubebuilder create webhook * golangci: configure varnamelen * api: linodevpc: add create validation
1 parent 4c9281b commit 87a2c65

13 files changed

+561
-3
lines changed

.golangci.yml

+3
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ linters-settings:
116116
require-explanation: true
117117
require-specific: true
118118

119+
varnamelen:
120+
min-name-length: 2
121+
119122
linters:
120123
enable:
121124
- asasalint

PROJECT

+12
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ resources:
4848
kind: LinodeMachineTemplate
4949
path: github.com/linode/cluster-api-provider-linode/api/v1alpha1
5050
version: v1alpha1
51+
- api:
52+
crdVersion: v1
53+
namespaced: true
54+
controller: true
55+
domain: cluster.x-k8s.io
56+
group: infrastructure
57+
kind: LinodeVPC
58+
path: github.com/linode/cluster-api-provider-linode/api/v1alpha1
59+
version: v1alpha1
60+
webhooks:
61+
validation: true
62+
webhookVersion: v1
5163
- api:
5264
crdVersion: v1
5365
namespaced: true

api/v1alpha1/linodevpc_webhook.go

+264
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
Copyright 2023 Akamai Technologies, Inc.
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 v1alpha1
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"net/netip"
24+
"regexp"
25+
"slices"
26+
"strings"
27+
28+
"go4.org/netipx"
29+
apierrors "k8s.io/apimachinery/pkg/api/errors"
30+
"k8s.io/apimachinery/pkg/runtime"
31+
"k8s.io/apimachinery/pkg/runtime/schema"
32+
"k8s.io/apimachinery/pkg/util/validation/field"
33+
ctrl "sigs.k8s.io/controller-runtime"
34+
logf "sigs.k8s.io/controller-runtime/pkg/log"
35+
"sigs.k8s.io/controller-runtime/pkg/webhook"
36+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
37+
38+
. "github.com/linode/cluster-api-provider-linode/clients"
39+
)
40+
41+
var (
42+
// The capability string indicating a region supports VPCs: [VPC Availability]
43+
//
44+
// [VPC Availability]: https://www.linode.com/docs/products/networking/vpc/#availability
45+
LinodeVPCCapability = "VPCs"
46+
47+
// The IPv4 ranges that are excluded from VPC Subnets: [Valid IPv4 Ranges for a Subnet]
48+
//
49+
// [Valid IPv4 Ranges for a Subnet]: https://www.linode.com/docs/products/networking/vpc/guides/subnets/#valid-ipv4-ranges
50+
LinodeVPCSubnetReserved = mustParseIPSet("192.168.128.0/17")
51+
52+
// IPv4 private address space as defined in [RFC 1918].
53+
//
54+
// [RFC 1918]: https://datatracker.ietf.org/doc/html/rfc1918
55+
privateIPv4 = mustParseIPSet("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
56+
)
57+
58+
// mustParseIPSet parses the given IP CIDRs as a [go4.org/netipx.IPSet]. It is intended for use with hard-coded strings.
59+
//
60+
//nolint:errcheck //^
61+
func mustParseIPSet(cidrs ...string) *netipx.IPSet {
62+
var (
63+
builder netipx.IPSetBuilder
64+
set *netipx.IPSet
65+
)
66+
for _, cidr := range cidrs {
67+
prefix, _ := netip.ParsePrefix(cidr)
68+
builder.AddPrefix(prefix)
69+
}
70+
set, _ = builder.IPSet()
71+
return set
72+
}
73+
74+
// log is for logging in this package.
75+
var linodevpclog = logf.Log.WithName("linodevpc-resource")
76+
77+
// SetupWebhookWithManager will setup the manager to manage the webhooks
78+
func (r *LinodeVPC) SetupWebhookWithManager(mgr ctrl.Manager) error {
79+
return ctrl.NewWebhookManagedBy(mgr).
80+
For(r).
81+
Complete()
82+
}
83+
84+
// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable updation and deletion validation.
85+
//+kubebuilder:webhook:path=/validate-infrastructure-cluster-x-k8s-io-v1alpha1-linodevpc,mutating=false,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=linodevpcs,verbs=create,versions=v1alpha1,name=vlinodevpc.kb.io,admissionReviewVersions=v1
86+
87+
var _ webhook.Validator = &LinodeVPC{}
88+
89+
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
90+
func (r *LinodeVPC) ValidateCreate() (admission.Warnings, error) {
91+
linodevpclog.Info("validate create", "name", r.Name)
92+
93+
ctx, cancel := context.WithTimeout(context.Background(), defaultWebhookTimeout)
94+
defer cancel()
95+
96+
return nil, r.validateLinodeVPC(ctx, &defaultLinodeClient)
97+
}
98+
99+
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
100+
func (r *LinodeVPC) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
101+
linodevpclog.Info("validate update", "name", r.Name)
102+
103+
// TODO(user): fill in your validation logic upon object update.
104+
return nil, nil
105+
}
106+
107+
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
108+
func (r *LinodeVPC) ValidateDelete() (admission.Warnings, error) {
109+
linodevpclog.Info("validate delete", "name", r.Name)
110+
111+
// TODO(user): fill in your validation logic upon object deletion.
112+
return nil, nil
113+
}
114+
115+
func (r *LinodeVPC) validateLinodeVPC(ctx context.Context, client LinodeClient) error {
116+
var errs field.ErrorList
117+
118+
if err := r.validateLinodeVPCSpec(ctx, client); err != nil {
119+
errs = slices.Concat(errs, err)
120+
}
121+
122+
if len(errs) == 0 {
123+
return nil
124+
}
125+
return apierrors.NewInvalid(
126+
schema.GroupKind{Group: "infrastructure.cluster.x-k8s.io", Kind: "LinodeVPC"},
127+
r.Name, errs)
128+
}
129+
130+
func (r *LinodeVPC) validateLinodeVPCSpec(ctx context.Context, client LinodeClient) field.ErrorList {
131+
var errs field.ErrorList
132+
133+
if err := validateRegion(ctx, client, r.Spec.Region, field.NewPath("spec").Child("region"), LinodeVPCCapability); err != nil {
134+
errs = append(errs, err)
135+
}
136+
if err := r.validateLinodeVPCSubnets(); err != nil {
137+
errs = slices.Concat(errs, err)
138+
}
139+
140+
if len(errs) == 0 {
141+
return nil
142+
}
143+
return errs
144+
}
145+
146+
func (r *LinodeVPC) validateLinodeVPCSubnets() field.ErrorList {
147+
var (
148+
errs field.ErrorList
149+
builder netipx.IPSetBuilder
150+
cidrs = &netipx.IPSet{}
151+
labels = []string{}
152+
)
153+
154+
for i, subnet := range r.Spec.Subnets {
155+
var (
156+
label = subnet.Label
157+
labelPath = field.NewPath("spec").Child("Subnets").Index(i).Child("Label")
158+
ip = subnet.IPv4
159+
ipPath = field.NewPath("spec").Child("Subnets").Index(i).Child("IPv4")
160+
)
161+
162+
// Validate Subnet Label
163+
if err := validateVPCLabel(label, labelPath); err != nil {
164+
errs = append(errs, err)
165+
} else if slices.Contains(labels, label) {
166+
errs = append(errs, field.Invalid(labelPath, label, "must be unique among the vpc's subnets"))
167+
} else {
168+
labels = append(labels, label)
169+
}
170+
171+
// Validate Subnet IP Address Range
172+
cidr, ferr := validateSubnetIPv4CIDR(ip, ipPath)
173+
if ferr != nil {
174+
errs = append(errs, ferr)
175+
continue
176+
}
177+
if cidrs.Overlaps(cidr) {
178+
errs = append(errs, field.Invalid(ipPath, ip, "range must not overlap with other subnets on the same vpc"))
179+
}
180+
var err error
181+
builder.AddSet(cidr)
182+
if cidrs, err = builder.IPSet(); err != nil {
183+
return append(field.ErrorList{}, field.InternalError(ipPath, fmt.Errorf("build ip set: %w", err)))
184+
}
185+
}
186+
187+
if len(errs) == 0 {
188+
return nil
189+
}
190+
return errs
191+
}
192+
193+
// TODO: Replace the OpenAPI schema validation for .metadata.name.
194+
//
195+
// validateVPCLabel validates a label string is a valid [Linode VPC Label].
196+
//
197+
// [Linode VPC Label]: https://www.linode.com/docs/api/vpcs/#vpc-create__request-body-schema
198+
func validateVPCLabel(label string, path *field.Path) *field.Error {
199+
var (
200+
minLen = 1
201+
maxLen = 64
202+
errs = []error{
203+
fmt.Errorf("%d..%d characters", minLen, maxLen),
204+
errors.New("can only contain ASCII letters, numbers, and hyphens (-)"),
205+
errors.New("cannot contain two consecutive hyphens (--)"),
206+
}
207+
regex = regexp.MustCompile("^[-[:alnum:]]*$")
208+
)
209+
if len(label) < minLen || len(label) > maxLen {
210+
return field.Invalid(path, label, errs[0].Error())
211+
}
212+
if !regex.MatchString(label) {
213+
return field.Invalid(path, label, errs[1].Error())
214+
}
215+
if strings.Contains(label, "--") {
216+
return field.Invalid(path, label, errs[2].Error())
217+
}
218+
return nil
219+
}
220+
221+
// validateSubnetIPv4CIDR validates a CIDR string is a valid [Linode VPC Subnet IPv4 Address Range].
222+
//
223+
// [Linode VPC Subnet IPv4 Address Range]: https://www.linode.com/docs/api/vpcs/#vpc-create__request-body-schema
224+
func validateSubnetIPv4CIDR(cidr string, path *field.Path) (*netipx.IPSet, *field.Error) {
225+
var (
226+
minPrefix = 1
227+
maxPrefix = 29
228+
errs = []error{
229+
errors.New("must be IPv4 range in CIDR canonical form"),
230+
errors.New("range must belong to a private address space as defined in RFC1918"),
231+
fmt.Errorf("allowed prefix lengths: %d-%d", minPrefix, maxPrefix),
232+
fmt.Errorf("%s %s", "range must not overlap with", LinodeVPCSubnetReserved.Prefixes()),
233+
}
234+
)
235+
236+
prefix, ferr := netip.ParsePrefix(cidr)
237+
if !(ferr == nil && prefix.Addr().Is4()) {
238+
return nil, field.Invalid(path, cidr, errs[0].Error())
239+
}
240+
if netipx.ComparePrefix(prefix, prefix.Masked()) != 0 {
241+
return nil, field.Invalid(path, cidr, errs[0].Error())
242+
}
243+
if !privateIPv4.ContainsPrefix(prefix) {
244+
return nil, field.Invalid(path, cidr, errs[1].Error())
245+
}
246+
size, _ := netipx.PrefixIPNet(prefix).Mask.Size()
247+
if size < minPrefix || size > maxPrefix {
248+
return nil, field.Invalid(path, cidr, errs[2].Error())
249+
}
250+
if LinodeVPCSubnetReserved.OverlapsPrefix(prefix) {
251+
return nil, field.Invalid(path, cidr, errs[3].Error())
252+
}
253+
254+
var (
255+
builder netipx.IPSetBuilder
256+
set *netipx.IPSet
257+
err error
258+
)
259+
builder.AddPrefix(prefix)
260+
if set, err = builder.IPSet(); err != nil {
261+
return nil, field.InternalError(path, fmt.Errorf("build ip set: %w", err))
262+
}
263+
return set, nil
264+
}

0 commit comments

Comments
 (0)