This is an automated email from the ASF dual-hosted git repository. ronething pushed a commit to branch feat/add_resource_verify in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
commit a726717ca61639d45f14d72bb21ad27e64212a21 Author: Ashing Zheng <[email protected]> AuthorDate: Thu Sep 25 17:45:12 2025 +0800 feat: support gateway proxy webhook Signed-off-by: Ashing Zheng <[email protected]> --- internal/manager/webhooks.go | 3 + internal/webhook/v1/gatewayproxy_webhook.go | 133 ++++++++++++++++++++ internal/webhook/v1/gatewayproxy_webhook_test.go | 152 +++++++++++++++++++++++ 3 files changed, 288 insertions(+) diff --git a/internal/manager/webhooks.go b/internal/manager/webhooks.go index 07b6f381..be0bd360 100644 --- a/internal/manager/webhooks.go +++ b/internal/manager/webhooks.go @@ -35,5 +35,8 @@ func setupWebhooks(_ context.Context, mgr manager.Manager) error { if err := webhookv1.SetupGatewayWebhookWithManager(mgr); err != nil { return err } + if err := webhookv1.SetupGatewayProxyWebhookWithManager(mgr); err != nil { + return err + } return nil } diff --git a/internal/webhook/v1/gatewayproxy_webhook.go b/internal/webhook/v1/gatewayproxy_webhook.go new file mode 100644 index 00000000..0abd5e34 --- /dev/null +++ b/internal/webhook/v1/gatewayproxy_webhook.go @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 v1 + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "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" + + v1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" +) + +var gatewayProxyLog = logf.Log.WithName("gatewayproxy-resource") + +func SetupGatewayProxyWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.GatewayProxy{}). + WithValidator(&GatewayProxyCustomValidator{Client: mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-apisix-apache-org-v1alpha1-gatewayproxy,mutating=false,failurePolicy=fail,sideEffects=None,groups=apisix.apache.org,resources=gatewayproxies,verbs=create;update,versions=v1alpha1,name=vgatewayproxy-v1alpha1.kb.io,admissionReviewVersions=v1 + +type GatewayProxyCustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &GatewayProxyCustomValidator{} + +func (v *GatewayProxyCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + gp, ok := obj.(*v1alpha1.GatewayProxy) + if !ok { + return nil, fmt.Errorf("expected a GatewayProxy object but got %T", obj) + } + gatewayProxyLog.Info("Validation for GatewayProxy upon creation", "name", gp.GetName(), "namespace", gp.GetNamespace()) + + return v.collectWarnings(ctx, gp), nil +} + +func (v *GatewayProxyCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + gp, ok := newObj.(*v1alpha1.GatewayProxy) + if !ok { + return nil, fmt.Errorf("expected a GatewayProxy object for the newObj but got %T", newObj) + } + gatewayProxyLog.Info("Validation for GatewayProxy upon update", "name", gp.GetName(), "namespace", gp.GetNamespace()) + + return v.collectWarnings(ctx, gp), nil +} + +func (v *GatewayProxyCustomValidator) ValidateDelete(context.Context, runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func (v *GatewayProxyCustomValidator) collectWarnings(ctx context.Context, gp *v1alpha1.GatewayProxy) admission.Warnings { + var warnings admission.Warnings + + warnings = append(warnings, v.warnIfProviderServiceMissing(ctx, gp)...) + warnings = append(warnings, v.warnIfAdminKeySecretMissing(ctx, gp)...) + + return warnings +} + +func (v *GatewayProxyCustomValidator) warnIfProviderServiceMissing(ctx context.Context, gp *v1alpha1.GatewayProxy) admission.Warnings { + if gp.Spec.Provider == nil || gp.Spec.Provider.ControlPlane == nil || gp.Spec.Provider.ControlPlane.Service == nil { + return nil + } + + svcRef := gp.Spec.Provider.ControlPlane.Service + key := client.ObjectKey{Namespace: gp.GetNamespace(), Name: svcRef.Name} + var svc corev1.Service + if err := v.Client.Get(ctx, key, &svc); err != nil { + if k8serrors.IsNotFound(err) { + msg := fmt.Sprintf("Referenced Service '%s/%s' not found at spec.provider.controlPlane.service", key.Namespace, key.Name) + gatewayProxyLog.Info("GatewayProxy references missing Service", "gatewayproxy", gp.GetName(), "namespace", key.Namespace, "service", key.Name) + return admission.Warnings{msg} + } + gatewayProxyLog.Error(err, "failed to resolve Service for GatewayProxy", "gatewayproxy", gp.GetName(), "namespace", key.Namespace, "service", key.Name) + } + return nil +} + +func (v *GatewayProxyCustomValidator) warnIfAdminKeySecretMissing(ctx context.Context, gp *v1alpha1.GatewayProxy) admission.Warnings { + if gp.Spec.Provider == nil || gp.Spec.Provider.ControlPlane == nil { + return nil + } + + auth := gp.Spec.Provider.ControlPlane.Auth + if auth.Type != v1alpha1.AuthTypeAdminKey || auth.AdminKey == nil || auth.AdminKey.ValueFrom == nil || auth.AdminKey.ValueFrom.SecretKeyRef == nil { + return nil + } + + ref := auth.AdminKey.ValueFrom.SecretKeyRef + key := client.ObjectKey{Namespace: gp.GetNamespace(), Name: ref.Name} + var secret corev1.Secret + if err := v.Client.Get(ctx, key, &secret); err != nil { + if k8serrors.IsNotFound(err) { + msg := fmt.Sprintf("Referenced Secret '%s/%s' not found at spec.provider.controlPlane.auth.adminKey.valueFrom.secretKeyRef", key.Namespace, key.Name) + gatewayProxyLog.Info("GatewayProxy references missing Secret", "gatewayproxy", gp.GetName(), "namespace", key.Namespace, "secret", key.Name) + return admission.Warnings{msg} + } + gatewayProxyLog.Error(err, "failed to resolve Secret for GatewayProxy", "gatewayproxy", gp.GetName(), "namespace", key.Namespace, "secret", key.Name) + return nil + } + + if _, ok := secret.Data[ref.Key]; !ok { + msg := fmt.Sprintf("Secret key '%s' not found in Secret '%s/%s' at spec.provider.controlPlane.auth.adminKey.valueFrom.secretKeyRef", ref.Key, key.Namespace, key.Name) + gatewayProxyLog.Info("GatewayProxy references Secret without required key", "gatewayproxy", gp.GetName(), "namespace", key.Namespace, "secret", key.Name, "key", ref.Key) + return admission.Warnings{msg} + } + + return nil +} diff --git a/internal/webhook/v1/gatewayproxy_webhook_test.go b/internal/webhook/v1/gatewayproxy_webhook_test.go new file mode 100644 index 00000000..03d310b7 --- /dev/null +++ b/internal/webhook/v1/gatewayproxy_webhook_test.go @@ -0,0 +1,152 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 v1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" +) + +func buildGatewayProxyValidator(t *testing.T, objects ...runtime.Object) *GatewayProxyCustomValidator { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + + builder := fake.NewClientBuilder().WithScheme(scheme) + if len(objects) > 0 { + builder = builder.WithRuntimeObjects(objects...) + } + + return &GatewayProxyCustomValidator{Client: builder.Build()} +} + +func newGatewayProxy() *v1alpha1.GatewayProxy { + return &v1alpha1.GatewayProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: v1alpha1.GatewayProxySpec{ + Provider: &v1alpha1.GatewayProxyProvider{ + Type: v1alpha1.ProviderTypeControlPlane, + ControlPlane: &v1alpha1.ControlPlaneProvider{ + Service: &v1alpha1.ProviderService{Name: "control-plane"}, + Auth: v1alpha1.ControlPlaneAuth{ + Type: v1alpha1.AuthTypeAdminKey, + AdminKey: &v1alpha1.AdminKeyAuth{ + ValueFrom: &v1alpha1.AdminKeyValueFrom{ + SecretKeyRef: &v1alpha1.SecretKeySelector{ + Name: "admin-key", + Key: "token", + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestGatewayProxyValidator_MissingService(t *testing.T) { + gp := newGatewayProxy() + gp.Spec.Provider.ControlPlane.Auth.AdminKey = nil + validator := buildGatewayProxyValidator(t) + + warnings, err := validator.ValidateCreate(context.Background(), gp) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Service 'default/control-plane' not found") +} + +func TestGatewayProxyValidator_MissingAdminSecret(t *testing.T) { + gp := newGatewayProxy() + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "control-plane", + Namespace: "default", + }, + } + validator := buildGatewayProxyValidator(t, service) + + warnings, err := validator.ValidateCreate(context.Background(), gp) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Secret 'default/admin-key' not found") +} + +func TestGatewayProxyValidator_MissingAdminSecretKey(t *testing.T) { + gp := newGatewayProxy() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "wrong": []byte("value"), + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "control-plane", + Namespace: "default", + }, + } + + validator := buildGatewayProxyValidator(t, secret, service) + + warnings, err := validator.ValidateCreate(context.Background(), gp) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Secret key 'token' not found") +} + +func TestGatewayProxyValidator_NoWarnings(t *testing.T) { + gp := newGatewayProxy() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "control-plane", + Namespace: "default", + }, + } + + validator := buildGatewayProxyValidator(t, secret, service) + + warnings, err := validator.ValidateCreate(context.Background(), gp) + require.NoError(t, err) + require.Empty(t, warnings) +}
