diff --git a/PROJECT b/PROJECT index 8263dd52..8f6755ba 100644 --- a/PROJECT +++ b/PROJECT @@ -39,4 +39,8 @@ resources: kind: ElfMachineTemplate path: github.com/smartxworks/cluster-api-provider-elf/api/v1beta1 version: v1beta1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 version: "3" diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index bbab1a91..2985df2a 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -23,3 +23,23 @@ webhooks: resources: - elfmachines sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-infrastructure-cluster-x-k8s-io-v1beta1-elfmachinetemplate + failurePolicy: Fail + name: mutation.elfmachinetemplate.infrastructure.x-k8s.io + rules: + - apiGroups: + - infrastructure.cluster.x-k8s.io + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - elfmachinetemplates + sideEffects: None diff --git a/go.mod b/go.mod index 77432cda..cf4f0182 100644 --- a/go.mod +++ b/go.mod @@ -127,7 +127,7 @@ require ( golang.org/x/term v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.3.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.3.0 google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect google.golang.org/protobuf v1.31.0 // indirect diff --git a/main.go b/main.go index 7c2972e5..177a04a0 100644 --- a/main.go +++ b/main.go @@ -198,6 +198,13 @@ func main() { }).SetupWebhookWithManager(mgr); err != nil { return err } + + if err := (&webhooks.ElfMachineTemplateMutation{ + Client: mgr.GetClient(), + Logger: mgr.GetLogger().WithName("ElfMachineTemplateMutation"), + }).SetupWebhookWithManager(mgr); err != nil { + return err + } } if err := controllers.AddClusterControllerToManager(ctx, mgr, controller.Options{MaxConcurrentReconciles: elfClusterConcurrency}); err != nil { diff --git a/test/helpers/envtest.go b/test/helpers/envtest.go index 10e5be5e..cfbd30bd 100644 --- a/test/helpers/envtest.go +++ b/test/helpers/envtest.go @@ -138,6 +138,13 @@ func NewTestEnvironment() *TestEnvironment { return err } + if err := (&webhooks.ElfMachineTemplateMutation{ + Client: mgr.GetClient(), + Logger: mgr.GetLogger().WithName("ElfMachineTemplateMutation"), + }).SetupWebhookWithManager(mgr); err != nil { + return err + } + return nil } diff --git a/webhooks/elfmachine_webhook_mutation_test.go b/webhooks/elfmachine_webhook_mutation_test.go index d3898357..8fd51214 100644 --- a/webhooks/elfmachine_webhook_mutation_test.go +++ b/webhooks/elfmachine_webhook_mutation_test.go @@ -15,3 +15,79 @@ limitations under the License. */ package webhooks + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/gomega" + "gomodules.xyz/jsonpatch/v2" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + infrav1 "github.com/smartxworks/cluster-api-provider-elf/api/v1beta1" + "github.com/smartxworks/cluster-api-provider-elf/pkg/version" + "github.com/smartxworks/cluster-api-provider-elf/test/fake" +) + +func init() { + scheme = runtime.NewScheme() + _ = infrav1.AddToScheme(scheme) + _ = admissionv1.AddToScheme(scheme) +} + +var ( + scheme *runtime.Scheme +) + +func TestElfMachineMutation(t *testing.T) { + g := NewWithT(t) + tests := []testCase{} + + elfMachine := fake.NewElfMachine(nil) + elfMachine.Annotations = nil + raw, err := marshal(elfMachine) + g.Expect(err).NotTo(HaveOccurred()) + tests = append(tests, testCase{ + name: "should set CAPE version", + admissionRequest: admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{Group: infrav1.GroupVersion.Group, Version: infrav1.GroupVersion.Version, Kind: "ElfMachine"}, + Operation: admissionv1.Create, + Object: runtime.RawExtension{Raw: raw}, + }}, + expectRespAllowed: true, + expectPatchs: []jsonpatch.Operation{ + {Operation: "add", Path: "/metadata/annotations", Value: map[string]interface{}{infrav1.CAPEVersionAnnotation: version.CAPEVersion()}}, + }, + }) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mutation := ElfMachineMutation{} + mutation.InjectDecoder(admission.NewDecoder(scheme)) + + resp := mutation.Handle(context.Background(), tc.admissionRequest) + g.Expect(resp.Allowed).Should(Equal(tc.expectRespAllowed)) + g.Expect(resp.Patches).Should(Equal(tc.expectPatchs)) + }) + } +} + +func marshal(obj client.Object) ([]byte, error) { + bs, err := json.Marshal(obj) + if err != nil { + return nil, err + } + return bs, nil +} + +type testCase struct { + name string + admissionRequest admission.Request + expectRespAllowed bool + expectPatchs []jsonpatch.Operation +} diff --git a/webhooks/elfmachinetemplate_webhook_mutation.go b/webhooks/elfmachinetemplate_webhook_mutation.go new file mode 100644 index 00000000..bf1cb0e8 --- /dev/null +++ b/webhooks/elfmachinetemplate_webhook_mutation.go @@ -0,0 +1,89 @@ +/* +Copyright 2024. + +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 webhooks + +import ( + goctx "context" + "encoding/json" + "net/http" + + "github.com/go-logr/logr" + "k8s.io/utils/pointer" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + infrav1 "github.com/smartxworks/cluster-api-provider-elf/api/v1beta1" +) + +const ( + defaultIPPoolAPIGroup = "ipam.metal3.io" + defaultIPPoolKind = "IPPool" +) + +func (m *ElfMachineTemplateMutation) SetupWebhookWithManager(mgr ctrl.Manager) error { + if m.decoder == nil { + m.decoder = admission.NewDecoder(mgr.GetScheme()) + } + + hookServer := mgr.GetWebhookServer() + hookServer.Register("/mutate-infrastructure-cluster-x-k8s-io-v1beta1-elfmachinetemplate", &webhook.Admission{Handler: m}) + return ctrl.NewWebhookManagedBy(mgr). + For(&infrav1.ElfMachine{}). + Complete() +} + +//+kubebuilder:object:generate=false +//+kubebuilder:webhook:verbs=create;update,path=/mutate-infrastructure-cluster-x-k8s-io-v1beta1-elfmachinetemplate,mutating=true,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=elfmachinetemplates,versions=v1beta1,name=mutation.elfmachinetemplate.infrastructure.x-k8s.io,admissionReviewVersions=v1 + +type ElfMachineTemplateMutation struct { + client.Client + decoder *admission.Decoder + logr.Logger +} + +func (m *ElfMachineTemplateMutation) Handle(ctx goctx.Context, request admission.Request) admission.Response { + var elfMachineTemplate infrav1.ElfMachineTemplate + if err := m.decoder.Decode(request, &elfMachineTemplate); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + devices := elfMachineTemplate.Spec.Template.Spec.Network.Devices + for i := 0; i < len(devices); i++ { + for j := 0; j < len(devices[i].AddressesFromPools); j++ { + if devices[i].AddressesFromPools[j].APIGroup == nil || *devices[i].AddressesFromPools[j].APIGroup == "" { + devices[i].AddressesFromPools[j].APIGroup = pointer.String(defaultIPPoolAPIGroup) + } + if devices[i].AddressesFromPools[j].Kind == "" { + devices[i].AddressesFromPools[j].Kind = defaultIPPoolKind + } + } + } + + if marshaledElfMachineTemplate, err := json.Marshal(elfMachineTemplate); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } else { + return admission.PatchResponseFromRaw(request.Object.Raw, marshaledElfMachineTemplate) + } +} + +// InjectDecoder injects the decoder. +func (m *ElfMachineTemplateMutation) InjectDecoder(d *admission.Decoder) error { + m.decoder = d + return nil +} diff --git a/webhooks/elfmachinetemplate_webhook_mutation_test.go b/webhooks/elfmachinetemplate_webhook_mutation_test.go new file mode 100644 index 00000000..64531f14 --- /dev/null +++ b/webhooks/elfmachinetemplate_webhook_mutation_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2024. + +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 webhooks + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + "gomodules.xyz/jsonpatch/v2" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + infrav1 "github.com/smartxworks/cluster-api-provider-elf/api/v1beta1" +) + +func TestElfMachineMutationTemplate(t *testing.T) { + g := NewWithT(t) + tests := []testCase{} + + elfMachineTemplate := &infrav1.ElfMachineTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "elfmachinetemplate", + }, + Spec: infrav1.ElfMachineTemplateSpec{ + Template: infrav1.ElfMachineTemplateResource{ + Spec: infrav1.ElfMachineSpec{}, + }, + }, + } + elfMachineTemplate.Spec.Template.Spec.Network.Devices = []infrav1.NetworkDeviceSpec{ + {AddressesFromPools: []corev1.TypedLocalObjectReference{{Name: "test"}}}, + {AddressesFromPools: []corev1.TypedLocalObjectReference{{Name: "test", APIGroup: pointer.String("")}}}, + {AddressesFromPools: []corev1.TypedLocalObjectReference{{Name: "test", APIGroup: pointer.String("apiGroup")}}}, + {AddressesFromPools: []corev1.TypedLocalObjectReference{{Name: "test", APIGroup: pointer.String("apiGroup"), Kind: "kind"}}}, + } + raw, err := marshal(elfMachineTemplate) + g.Expect(err).NotTo(HaveOccurred()) + tests = append(tests, testCase{ + name: "should set default values for network devices", + admissionRequest: admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{Group: infrav1.GroupVersion.Group, Version: infrav1.GroupVersion.Version, Kind: "ElfMachine"}, + Operation: admissionv1.Create, + Object: runtime.RawExtension{Raw: raw}, + }}, + expectRespAllowed: true, + expectPatchs: []jsonpatch.Operation{ + {Operation: "replace", Path: "/spec/template/spec/network/devices/0/addressesFromPools/0/apiGroup", Value: defaultIPPoolAPIGroup}, + {Operation: "replace", Path: "/spec/template/spec/network/devices/0/addressesFromPools/0/kind", Value: defaultIPPoolKind}, + {Operation: "replace", Path: "/spec/template/spec/network/devices/1/addressesFromPools/0/apiGroup", Value: defaultIPPoolAPIGroup}, + {Operation: "replace", Path: "/spec/template/spec/network/devices/1/addressesFromPools/0/kind", Value: defaultIPPoolKind}, + {Operation: "replace", Path: "/spec/template/spec/network/devices/2/addressesFromPools/0/kind", Value: defaultIPPoolKind}, + }, + }) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mutation := ElfMachineTemplateMutation{} + mutation.InjectDecoder(admission.NewDecoder(scheme)) + + resp := mutation.Handle(context.Background(), tc.admissionRequest) + g.Expect(resp.Allowed).Should(Equal(tc.expectRespAllowed)) + g.Expect(resp.Patches).Should(HaveLen(len(tc.expectPatchs))) + g.Expect(resp.Patches).Should(ContainElements(tc.expectPatchs)) + }) + } +}