diff --git a/api/v1alpha1/pattern_webhook.go b/api/v1alpha1/pattern_webhook.go new file mode 100644 index 000000000..945400e5d --- /dev/null +++ b/api/v1alpha1/pattern_webhook.go @@ -0,0 +1,100 @@ +/* +Copyright 2022. + +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 ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var patternlog = logf.Log.WithName("pattern-resource") + +// +kubebuilder:object:generate=false +// +k8s:deepcopy-gen=false +// +k8s:openapi-gen=false +// PatternValidator validates Pattern resources to enforce singleton semantics. +type PatternValidator struct { + Client client.Client +} + +//nolint:lll +// +kubebuilder:webhook:verbs=create,path=/validate-gitops-hybrid-cloud-patterns-io-v1alpha1-pattern,mutating=false,failurePolicy=fail,groups=gitops.hybrid-cloud-patterns.io,resources=patterns,versions=v1alpha1,name=vpattern.gitops.hybrid-cloud-patterns.io,admissionReviewVersions=v1,sideEffects=none + +var _ webhook.CustomValidator = &PatternValidator{} + +// SetupWebhookWithManager will setup the manager to manage the webhooks +func (r *PatternValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + r.Client = mgr.GetClient() + return ctrl.NewWebhookManagedBy(mgr). + For(&Pattern{}). + WithValidator(r). + Complete() +} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type +func (r *PatternValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + p, err := convertToPattern(obj) + if err != nil { + return nil, err + } + patternlog.Info("validate create", "name", p.Name) + + var patterns PatternList + if err = r.Client.List(ctx, &patterns); err != nil { + return nil, fmt.Errorf("failed to list Pattern resources: %v", err) + } + if len(patterns.Items) > 0 { + return nil, fmt.Errorf("only one Pattern resource is allowed") + } + + return nil, nil +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type +func (r *PatternValidator) ValidateUpdate(_ context.Context, _, newObj runtime.Object) (admission.Warnings, error) { + p, err := convertToPattern(newObj) + if err != nil { + return nil, err + } + patternlog.Info("validate update", "name", p.Name) + return nil, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type +func (r *PatternValidator) ValidateDelete(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + p, err := convertToPattern(obj) + if err != nil { + return nil, err + } + patternlog.Info("validate delete", "name", p.Name) + return nil, nil +} + +func convertToPattern(obj runtime.Object) (*Pattern, error) { + p, ok := obj.(*Pattern) + if !ok { + return nil, fmt.Errorf("expected a Pattern object but got %T", obj) + } + return p, nil +} diff --git a/api/v1alpha1/pattern_webhook_test.go b/api/v1alpha1/pattern_webhook_test.go new file mode 100644 index 000000000..d3edfe4dc --- /dev/null +++ b/api/v1alpha1/pattern_webhook_test.go @@ -0,0 +1,168 @@ +/* +Copyright 2022. + +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 ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestValidateCreate_AllowsFirstPattern(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + validator := &PatternValidator{Client: fakeClient} + + p := &Pattern{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pattern", + Namespace: "default", + }, + Spec: PatternSpec{ + ClusterGroupName: "hub", + GitConfig: GitConfig{ + TargetRepo: "https://github.com/example/repo", + TargetRevision: "main", + }, + }, + } + + warnings, err := validator.ValidateCreate(context.Background(), p) + if err != nil { + t.Errorf("expected no error for first pattern, got: %v", err) + } + if warnings != nil { + t.Errorf("expected no warnings, got: %v", warnings) + } +} + +func TestValidateCreate_DeniesSecondPattern(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + existing := &Pattern{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-pattern", + Namespace: "default", + }, + Spec: PatternSpec{ + ClusterGroupName: "hub", + GitConfig: GitConfig{ + TargetRepo: "https://github.com/example/repo", + TargetRevision: "main", + }, + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() + validator := &PatternValidator{Client: fakeClient} + + p := &Pattern{ + ObjectMeta: metav1.ObjectMeta{ + Name: "second-pattern", + Namespace: "default", + }, + Spec: PatternSpec{ + ClusterGroupName: "hub", + GitConfig: GitConfig{ + TargetRepo: "https://github.com/example/repo2", + TargetRevision: "main", + }, + }, + } + + _, err := validator.ValidateCreate(context.Background(), p) + if err == nil { + t.Error("expected error when creating second pattern, got nil") + } +} + +func TestValidateCreate_RejectsNonPatternObject(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + validator := &PatternValidator{Client: fakeClient} + + notAPattern := &PatternList{} + + _, err := validator.ValidateCreate(context.Background(), notAPattern) + if err == nil { + t.Error("expected error for non-Pattern object, got nil") + } +} + +func TestValidateUpdate_Allows(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + validator := &PatternValidator{Client: fakeClient} + + p := &Pattern{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pattern", + Namespace: "default", + }, + } + + warnings, err := validator.ValidateUpdate(context.Background(), p, p) + if err != nil { + t.Errorf("expected no error on update, got: %v", err) + } + if warnings != nil { + t.Errorf("expected no warnings, got: %v", warnings) + } +} + +func TestValidateDelete_Allows(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + validator := &PatternValidator{Client: fakeClient} + + p := &Pattern{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pattern", + Namespace: "default", + }, + } + + warnings, err := validator.ValidateDelete(context.Background(), p) + if err != nil { + t.Errorf("expected no error on delete, got: %v", err) + } + if warnings != nil { + t.Errorf("expected no warnings, got: %v", warnings) + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 31d506de8..1a493c594 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1alpha1 import ( - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/bundle/manifests/patterns-operator-webhook-service_v1_service.yaml b/bundle/manifests/patterns-operator-webhook-service_v1_service.yaml new file mode 100644 index 000000000..7dc6a9d50 --- /dev/null +++ b/bundle/manifests/patterns-operator-webhook-service_v1_service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: patterns-operator + name: patterns-operator-webhook-service +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager +status: + loadBalancer: {} diff --git a/cmd/main.go b/cmd/main.go index 8fd068306..b6edf5582 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -108,6 +108,12 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Pattern") os.Exit(1) } + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err = (&gitopsv1alpha1.PatternValidator{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Pattern") + os.Exit(1) + } + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index c2626246b..4185b6887 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -18,7 +18,7 @@ bases: - ../manager # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- ../webhook +- ../webhook # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. #- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. @@ -36,7 +36,7 @@ patchesStrategicMerge: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- manager_webhook_patch.yaml +- manager_webhook_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 000000000..2b4fccff8 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system + labels: + app.kubernetes.io/name: patterns-operator + app.kubernetes.io/managed-by: kustomize +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 000000000..9cf26134e --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 000000000..206316e54 --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,22 @@ +# the following config is for teaching kustomize where to look at when substituting nameReference. +# It requires kustomize v2.1.0 or newer to work properly. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 000000000..a44930560 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-gitops-hybrid-cloud-patterns-io-v1alpha1-pattern + failurePolicy: Fail + name: vpattern.gitops.hybrid-cloud-patterns.io + rules: + - apiGroups: + - gitops.hybrid-cloud-patterns.io + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - patterns + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 000000000..af6d5de12 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: patterns-operator + app.kubernetes.io/managed-by: kustomize + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager