diff --git a/api/v1alpha1/templatechain_types.go b/api/v1alpha1/templatechain_types.go index 41bedc52..16e8cc12 100644 --- a/api/v1alpha1/templatechain_types.go +++ b/api/v1alpha1/templatechain_types.go @@ -26,6 +26,7 @@ type ClusterTemplateChain struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Spec is immutable" Spec TemplateChainSpec `json:"spec,omitempty"` } @@ -46,6 +47,7 @@ type ServiceTemplateChain struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Spec is immutable" Spec TemplateChainSpec `json:"spec,omitempty"` } diff --git a/cmd/main.go b/cmd/main.go index 0861ecf7..03419819 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -278,6 +278,13 @@ func main() { SystemNamespace: currentNamespace, }).SetupWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "TemplateManagement") + } + if err := (&hmcwebhook.ClusterTemplateChainValidator{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "ClusterTemplateChain") + os.Exit(1) + } + if err := (&hmcwebhook.ServiceTemplateChainValidator{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "ServiceTemplateChain") os.Exit(1) } if err := (&hmcwebhook.ClusterTemplateValidator{}).SetupWebhookWithManager(mgr); err != nil { diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 08ac0b3a..622de0f1 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -144,6 +144,12 @@ var _ = BeforeSuite(func() { err = (&hmcwebhook.TemplateManagementValidator{}).SetupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = (&hmcwebhook.ClusterTemplateChainValidator{}).SetupWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&hmcwebhook.ServiceTemplateChainValidator{}).SetupWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + err = (&hmcwebhook.ClusterTemplateValidator{}).SetupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) diff --git a/internal/webhook/templatechain_webhook.go b/internal/webhook/templatechain_webhook.go new file mode 100644 index 00000000..08150940 --- /dev/null +++ b/internal/webhook/templatechain_webhook.go @@ -0,0 +1,145 @@ +// 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 webhook // nolint:dupl + +import ( + "context" + "errors" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + 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" + + "github.com/Mirantis/hmc/api/v1alpha1" +) + +var ( + ErrInvalidTemplateChainSpec = errors.New("the template chain spec is invalid") +) + +type ClusterTemplateChainValidator struct { + client.Client +} + +func (in *ClusterTemplateChainValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + in.Client = mgr.GetClient() + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.ClusterTemplateChain{}). + WithValidator(in). + WithDefaulter(in). + Complete() +} + +var ( + _ webhook.CustomValidator = &ClusterTemplateChainValidator{} + _ webhook.CustomDefaulter = &ClusterTemplateChainValidator{} +) + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type. +func (*ClusterTemplateChainValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + chain, ok := obj.(*v1alpha1.ClusterTemplateChain) + if !ok { + return admission.Warnings{"Wrong object"}, apierrors.NewBadRequest(fmt.Sprintf("expected ClusterTemplateChain but got a %T", obj)) + } + + warnings := isTemplateChainValid(chain.Spec) + if len(warnings) > 0 { + return warnings, ErrInvalidTemplateChainSpec + } + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type. +func (*ClusterTemplateChainValidator) ValidateUpdate(_ context.Context, _ runtime.Object, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type. +func (*ClusterTemplateChainValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// Default implements webhook.Defaulter so a webhook will be registered for the type. +func (*ClusterTemplateChainValidator) Default(_ context.Context, _ runtime.Object) error { + return nil +} + +type ServiceTemplateChainValidator struct { + client.Client +} + +func (in *ServiceTemplateChainValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + in.Client = mgr.GetClient() + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.ServiceTemplateChain{}). + WithValidator(in). + WithDefaulter(in). + Complete() +} + +var ( + _ webhook.CustomValidator = &ServiceTemplateChainValidator{} + _ webhook.CustomDefaulter = &ServiceTemplateChainValidator{} +) + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type. +func (*ServiceTemplateChainValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + chain, ok := obj.(*v1alpha1.ServiceTemplateChain) + if !ok { + return admission.Warnings{"Wrong object"}, apierrors.NewBadRequest(fmt.Sprintf("expected ServiceTemplateChain but got a %T", obj)) + } + warnings := isTemplateChainValid(chain.Spec) + if len(warnings) > 0 { + return warnings, ErrInvalidTemplateChainSpec + } + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type. +func (*ServiceTemplateChainValidator) ValidateUpdate(_ context.Context, _ runtime.Object, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type. +func (*ServiceTemplateChainValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// Default implements webhook.Defaulter so a webhook will be registered for the type. +func (*ServiceTemplateChainValidator) Default(_ context.Context, _ runtime.Object) error { + return nil +} + +func isTemplateChainValid(spec v1alpha1.TemplateChainSpec) admission.Warnings { + supportedTemplates := make(map[string]bool) + availableForUpgrade := make(map[string]bool) + for _, supportedTemplate := range spec.SupportedTemplates { + supportedTemplates[supportedTemplate.Name] = true + for _, template := range supportedTemplate.AvailableUpgrades { + availableForUpgrade[template.Name] = true + } + } + warnings := admission.Warnings{} + for template := range availableForUpgrade { + if !supportedTemplates[template] { + warnings = append(warnings, fmt.Sprintf("template %s is allowed for upgrade but is not present in the list of spec.SupportedTemplates", template)) + } + } + return warnings +} diff --git a/internal/webhook/templatechain_webhook_test.go b/internal/webhook/templatechain_webhook_test.go new file mode 100644 index 00000000..331c0452 --- /dev/null +++ b/internal/webhook/templatechain_webhook_test.go @@ -0,0 +1,90 @@ +// 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 webhook + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Mirantis/hmc/api/v1alpha1" + tc "github.com/Mirantis/hmc/test/objects/templatechain" + "github.com/Mirantis/hmc/test/scheme" +) + +func TestClusterTemplateChainValidateCreate(t *testing.T) { + g := NewWithT(t) + + ctx := context.Background() + + upgradeFromTemplateName := "template-1-0-1" + upgradeToTemplateName := "template-1-0-2" + supportedTemplates := []v1alpha1.SupportedTemplate{ + { + Name: upgradeFromTemplateName, + AvailableUpgrades: []v1alpha1.AvailableUpgrade{ + { + Name: upgradeToTemplateName, + }, + }, + }, + } + + tests := []struct { + name string + chain *v1alpha1.ClusterTemplateChain + existingObjects []runtime.Object + err string + warnings admission.Warnings + }{ + { + name: "should fail if spec is invalid: incorrect supported templates", + chain: tc.NewClusterTemplateChain(tc.WithName("test"), tc.WithSupportedTemplates(supportedTemplates)), + warnings: admission.Warnings{ + "template template-1-0-2 is allowed for upgrade but is not present in the list of spec.SupportedTemplates", + }, + err: "the template chain spec is invalid", + }, + { + name: "should succeed", + chain: tc.NewClusterTemplateChain(tc.WithName("test"), tc.WithSupportedTemplates(append(supportedTemplates, v1alpha1.SupportedTemplate{Name: upgradeToTemplateName}))), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithRuntimeObjects(tt.existingObjects...).Build() + validator := &ClusterTemplateChainValidator{Client: c} + warn, err := validator.ValidateCreate(ctx, tt.chain) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + if err.Error() != tt.err { + t.Fatalf("expected error '%s', got error: %s", tt.err, err.Error()) + } + } else { + g.Expect(err).To(Succeed()) + } + if len(tt.warnings) > 0 { + g.Expect(warn).To(Equal(tt.warnings)) + } else { + g.Expect(warn).To(BeEmpty()) + } + }) + } +} diff --git a/templates/provider/hmc/templates/crds/hmc.mirantis.com_clustertemplatechains.yaml b/templates/provider/hmc/templates/crds/hmc.mirantis.com_clustertemplatechains.yaml index c6c52f99..c2aed20c 100644 --- a/templates/provider/hmc/templates/crds/hmc.mirantis.com_clustertemplatechains.yaml +++ b/templates/provider/hmc/templates/crds/hmc.mirantis.com_clustertemplatechains.yaml @@ -70,6 +70,9 @@ spec: type: object type: array type: object + x-kubernetes-validations: + - message: Spec is immutable + rule: self == oldSelf type: object served: true storage: true diff --git a/templates/provider/hmc/templates/crds/hmc.mirantis.com_servicetemplatechains.yaml b/templates/provider/hmc/templates/crds/hmc.mirantis.com_servicetemplatechains.yaml index d6120df7..881235c0 100644 --- a/templates/provider/hmc/templates/crds/hmc.mirantis.com_servicetemplatechains.yaml +++ b/templates/provider/hmc/templates/crds/hmc.mirantis.com_servicetemplatechains.yaml @@ -70,6 +70,9 @@ spec: type: object type: array type: object + x-kubernetes-validations: + - message: Spec is immutable + rule: self == oldSelf type: object served: true storage: true diff --git a/templates/provider/hmc/templates/webhooks.yaml b/templates/provider/hmc/templates/webhooks.yaml index 2ff5f174..0dc316e1 100644 --- a/templates/provider/hmc/templates/webhooks.yaml +++ b/templates/provider/hmc/templates/webhooks.yaml @@ -150,4 +150,46 @@ webhooks: resources: - templatemanagements sideEffects: None + - admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: {{ include "hmc.webhook.serviceName" . }} + namespace: {{ include "hmc.webhook.serviceNamespace" . }} + path: /validate-hmc-mirantis-com-v1alpha1-clustertemplatechain + failurePolicy: Fail + matchPolicy: Equivalent + name: validation.clustertemplatechain.hmc.mirantis.com + rules: + - apiGroups: + - hmc.mirantis.com + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - clustertemplatechains + sideEffects: None + - admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: {{ include "hmc.webhook.serviceName" . }} + namespace: {{ include "hmc.webhook.serviceNamespace" . }} + path: /validate-hmc-mirantis-com-v1alpha1-servicetemplatechain + failurePolicy: Fail + matchPolicy: Equivalent + name: validation.servicetemplatechain.hmc.mirantis.com + rules: + - apiGroups: + - hmc.mirantis.com + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - servicetemplatechains + sideEffects: None {{- end }}