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 22da8f7091950de04f683b87554815611ac4399e Author: Ashing Zheng <[email protected]> AuthorDate: Fri Sep 26 16:36:03 2025 +0800 feat: add apisix route and apisix consumer Signed-off-by: Ashing Zheng <[email protected]> --- config/webhook/manifests.yaml | 40 ++++++ internal/manager/webhooks.go | 6 + internal/webhook/v1/apisixroute_webhook.go | 139 +++++++++++++++++++++ internal/webhook/v1/apisixroute_webhook_test.go | 158 ++++++++++++++++++++++++ internal/webhook/v1/consumer_webhook.go | 105 ++++++++++++++++ internal/webhook/v1/consumer_webhook_test.go | 130 +++++++++++++++++++ 6 files changed, 578 insertions(+) diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index de7ced0e..b5b33201 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -24,6 +24,26 @@ webhooks: resources: - apisixconsumers sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-apisix-apache-org-v2-apisixroute + failurePolicy: Fail + name: vapisixroute-v2.kb.io + rules: + - apiGroups: + - apisix.apache.org + apiVersions: + - v2 + operations: + - CREATE + - UPDATE + resources: + - apisixroutes + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -44,6 +64,26 @@ webhooks: resources: - apisixtlses sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-apisix-apache-org-v1alpha1-consumer + failurePolicy: Fail + name: vconsumer-v1alpha1.kb.io + rules: + - apiGroups: + - apisix.apache.org + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - consumers + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/manager/webhooks.go b/internal/manager/webhooks.go index 91242972..6907d762 100644 --- a/internal/manager/webhooks.go +++ b/internal/manager/webhooks.go @@ -44,5 +44,11 @@ func setupWebhooks(_ context.Context, mgr manager.Manager) error { if err := webhookv1.SetupApisixTlsWebhookWithManager(mgr); err != nil { return err } + if err := webhookv1.SetupApisixRouteWebhookWithManager(mgr); err != nil { + return err + } + if err := webhookv1.SetupConsumerWebhookWithManager(mgr); err != nil { + return err + } return nil } diff --git a/internal/webhook/v1/apisixroute_webhook.go b/internal/webhook/v1/apisixroute_webhook.go new file mode 100644 index 00000000..a651f913 --- /dev/null +++ b/internal/webhook/v1/apisixroute_webhook.go @@ -0,0 +1,139 @@ +// 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" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + 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" + + apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/internal/webhook/v1/reference" +) + +var apisixRouteLog = logf.Log.WithName("apisixroute-resource") + +func SetupApisixRouteWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&apisixv2.ApisixRoute{}). + WithValidator(&ApisixRouteCustomValidator{Client: mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-apisix-apache-org-v2-apisixroute,mutating=false,failurePolicy=fail,sideEffects=None,groups=apisix.apache.org,resources=apisixroutes,verbs=create;update,versions=v2,name=vapisixroute-v2.kb.io,admissionReviewVersions=v1 + +type ApisixRouteCustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &ApisixRouteCustomValidator{} + +func (v *ApisixRouteCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + route, ok := obj.(*apisixv2.ApisixRoute) + if !ok { + return nil, fmt.Errorf("expected an ApisixRoute object but got %T", obj) + } + apisixRouteLog.Info("Validation for ApisixRoute upon creation", "name", route.GetName(), "namespace", route.GetNamespace()) + + return v.collectWarnings(ctx, route), nil +} + +func (v *ApisixRouteCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + route, ok := newObj.(*apisixv2.ApisixRoute) + if !ok { + return nil, fmt.Errorf("expected an ApisixRoute object for the newObj but got %T", newObj) + } + apisixRouteLog.Info("Validation for ApisixRoute upon update", "name", route.GetName(), "namespace", route.GetNamespace()) + + return v.collectWarnings(ctx, route), nil +} + +func (*ApisixRouteCustomValidator) ValidateDelete(context.Context, runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func (v *ApisixRouteCustomValidator) collectWarnings(ctx context.Context, route *apisixv2.ApisixRoute) admission.Warnings { + checker := reference.NewChecker(v.Client, apisixRouteLog) + namespace := route.GetNamespace() + + serviceVisited := make(map[types.NamespacedName]struct{}) + secretVisited := make(map[types.NamespacedName]struct{}) + + var warnings admission.Warnings + + addServiceWarning := func(nn types.NamespacedName) { + if nn.Name == "" || nn.Namespace == "" { + return + } + if _, seen := serviceVisited[nn]; seen { + return + } + serviceVisited[nn] = struct{}{} + warnings = append(warnings, checker.Service(ctx, reference.ServiceRef{ + Object: route, + NamespacedName: nn, + })...) + } + + addSecretWarning := func(nn types.NamespacedName) { + if nn.Name == "" || nn.Namespace == "" { + return + } + if _, seen := secretVisited[nn]; seen { + return + } + secretVisited[nn] = struct{}{} + warnings = append(warnings, checker.Secret(ctx, reference.SecretRef{ + Object: route, + NamespacedName: nn, + })...) + } + + for _, rule := range route.Spec.HTTP { + for _, backend := range rule.Backends { + addServiceWarning(types.NamespacedName{Namespace: namespace, Name: backend.ServiceName}) + } + for _, plugin := range rule.Plugins { + if !plugin.Enable { + continue + } + if plugin.SecretRef != "" { + addSecretWarning(types.NamespacedName{Namespace: namespace, Name: plugin.SecretRef}) + } + } + } + + for _, rule := range route.Spec.Stream { + addServiceWarning(types.NamespacedName{Namespace: namespace, Name: rule.Backend.ServiceName}) + for _, plugin := range rule.Plugins { + if !plugin.Enable { + continue + } + if plugin.SecretRef != "" { + addSecretWarning(types.NamespacedName{Namespace: namespace, Name: plugin.SecretRef}) + } + } + } + + return warnings +} diff --git a/internal/webhook/v1/apisixroute_webhook_test.go b/internal/webhook/v1/apisixroute_webhook_test.go new file mode 100644 index 00000000..3ea88e46 --- /dev/null +++ b/internal/webhook/v1/apisixroute_webhook_test.go @@ -0,0 +1,158 @@ +// 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" + + apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" +) + +func buildApisixRouteValidator(t *testing.T, objects ...runtime.Object) *ApisixRouteCustomValidator { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, apisixv2.AddToScheme(scheme)) + + builder := fake.NewClientBuilder().WithScheme(scheme) + if len(objects) > 0 { + builder = builder.WithRuntimeObjects(objects...) + } + + return &ApisixRouteCustomValidator{Client: builder.Build()} +} + +func TestApisixRouteValidator_MissingHTTPService(t *testing.T) { + route := &apisixv2.ApisixRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv2.ApisixRouteSpec{ + HTTP: []apisixv2.ApisixRouteHTTP{{ + Name: "rule", + Backends: []apisixv2.ApisixRouteHTTPBackend{{ + ServiceName: "backend", + }}, + }}, + }, + } + + validator := buildApisixRouteValidator(t) + + warnings, err := validator.ValidateCreate(context.Background(), route) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Referenced Service 'default/backend' not found") +} + +func TestApisixRouteValidator_MissingPluginSecret(t *testing.T) { + route := &apisixv2.ApisixRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv2.ApisixRouteSpec{ + HTTP: []apisixv2.ApisixRouteHTTP{{ + Name: "rule", + Backends: []apisixv2.ApisixRouteHTTPBackend{{ + ServiceName: "backend", + }}, + Plugins: []apisixv2.ApisixRoutePlugin{{ + Name: "jwt-auth", + Enable: true, + SecretRef: "jwt-secret", + }}, + }}, + }, + } + + backendSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "backend", Namespace: "default"}} + + validator := buildApisixRouteValidator(t, backendSvc) + + warnings, err := validator.ValidateCreate(context.Background(), route) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Referenced Secret 'default/jwt-secret' not found") +} + +func TestApisixRouteValidator_MissingStreamService(t *testing.T) { + route := &apisixv2.ApisixRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv2.ApisixRouteSpec{ + Stream: []apisixv2.ApisixRouteStream{{ + Name: "stream", + Protocol: "TCP", + Backend: apisixv2.ApisixRouteStreamBackend{ + ServiceName: "stream-svc", + }, + }}, + }, + } + + validator := buildApisixRouteValidator(t) + + warnings, err := validator.ValidateCreate(context.Background(), route) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Referenced Service 'default/stream-svc' not found") +} + +func TestApisixRouteValidator_NoWarnings(t *testing.T) { + route := &apisixv2.ApisixRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv2.ApisixRouteSpec{ + HTTP: []apisixv2.ApisixRouteHTTP{{ + Name: "rule", + Backends: []apisixv2.ApisixRouteHTTPBackend{{ + ServiceName: "backend", + }}, + Plugins: []apisixv2.ApisixRoutePlugin{{ + Name: "jwt-auth", + Enable: true, + SecretRef: "jwt-secret", + }}, + }}, + }, + } + + objs := []runtime.Object{ + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "backend", Namespace: "default"}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: "default"}}, + } + + validator := buildApisixRouteValidator(t, objs...) + + warnings, err := validator.ValidateCreate(context.Background(), route) + require.NoError(t, err) + require.Empty(t, warnings) +} diff --git a/internal/webhook/v1/consumer_webhook.go b/internal/webhook/v1/consumer_webhook.go new file mode 100644 index 00000000..c640cdfe --- /dev/null +++ b/internal/webhook/v1/consumer_webhook.go @@ -0,0 +1,105 @@ +// 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" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + 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" + + apisixv1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" + "github.com/apache/apisix-ingress-controller/internal/webhook/v1/reference" +) + +var consumerLog = logf.Log.WithName("consumer-resource") + +func SetupConsumerWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&apisixv1alpha1.Consumer{}). + WithValidator(&ConsumerCustomValidator{Client: mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-apisix-apache-org-v1alpha1-consumer,mutating=false,failurePolicy=fail,sideEffects=None,groups=apisix.apache.org,resources=consumers,verbs=create;update,versions=v1alpha1,name=vconsumer-v1alpha1.kb.io,admissionReviewVersions=v1 + +type ConsumerCustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &ConsumerCustomValidator{} + +func (v *ConsumerCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + consumer, ok := obj.(*apisixv1alpha1.Consumer) + if !ok { + return nil, fmt.Errorf("expected a Consumer object but got %T", obj) + } + consumerLog.Info("Validation for Consumer upon creation", "name", consumer.GetName(), "namespace", consumer.GetNamespace()) + + return v.collectWarnings(ctx, consumer), nil +} + +func (v *ConsumerCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + consumer, ok := newObj.(*apisixv1alpha1.Consumer) + if !ok { + return nil, fmt.Errorf("expected a Consumer object for the newObj but got %T", newObj) + } + consumerLog.Info("Validation for Consumer upon update", "name", consumer.GetName(), "namespace", consumer.GetNamespace()) + + return v.collectWarnings(ctx, consumer), nil +} + +func (*ConsumerCustomValidator) ValidateDelete(context.Context, runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func (v *ConsumerCustomValidator) collectWarnings(ctx context.Context, consumer *apisixv1alpha1.Consumer) admission.Warnings { + checker := reference.NewChecker(v.Client, consumerLog) + defaultNamespace := consumer.GetNamespace() + + visited := make(map[types.NamespacedName]struct{}) + var warnings admission.Warnings + + for _, credential := range consumer.Spec.Credentials { + if credential.SecretRef == nil || credential.SecretRef.Name == "" { + continue + } + + namespace := defaultNamespace + if credential.SecretRef.Namespace != nil && *credential.SecretRef.Namespace != "" { + namespace = *credential.SecretRef.Namespace + } + + nn := types.NamespacedName{Namespace: namespace, Name: credential.SecretRef.Name} + if _, ok := visited[nn]; ok { + continue + } + visited[nn] = struct{}{} + + warnings = append(warnings, checker.Secret(ctx, reference.SecretRef{ + Object: consumer, + NamespacedName: nn, + })...) + } + + return warnings +} diff --git a/internal/webhook/v1/consumer_webhook_test.go b/internal/webhook/v1/consumer_webhook_test.go new file mode 100644 index 00000000..90ea0ae8 --- /dev/null +++ b/internal/webhook/v1/consumer_webhook_test.go @@ -0,0 +1,130 @@ +// 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" + + apisixv1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1" +) + +func buildConsumerValidator(t *testing.T, objects ...runtime.Object) *ConsumerCustomValidator { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, apisixv1alpha1.AddToScheme(scheme)) + + builder := fake.NewClientBuilder().WithScheme(scheme) + if len(objects) > 0 { + builder = builder.WithRuntimeObjects(objects...) + } + + return &ConsumerCustomValidator{Client: builder.Build()} +} + +func TestConsumerValidator_MissingSecretDefaultNamespace(t *testing.T) { + consumer := &apisixv1alpha1.Consumer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv1alpha1.ConsumerSpec{ + Credentials: []apisixv1alpha1.Credential{{ + Type: "jwt-auth", + SecretRef: &apisixv1alpha1.SecretReference{ + Name: "jwt-secret", + }, + }}, + }, + } + + validator := buildConsumerValidator(t) + + warnings, err := validator.ValidateCreate(context.Background(), consumer) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Referenced Secret 'default/jwt-secret' not found") +} + +func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) { + ns := "auth" + consumer := &apisixv1alpha1.Consumer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv1alpha1.ConsumerSpec{ + Credentials: []apisixv1alpha1.Credential{{ + Type: "jwt-auth", + SecretRef: &apisixv1alpha1.SecretReference{ + Name: "jwt-secret", + Namespace: &ns, + }, + }}, + }, + } + + validator := buildConsumerValidator(t) + + warnings, err := validator.ValidateCreate(context.Background(), consumer) + require.NoError(t, err) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Referenced Secret 'auth/jwt-secret' not found") +} + +func TestConsumerValidator_NoWarnings(t *testing.T) { + ns := "auth" + consumer := &apisixv1alpha1.Consumer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + }, + Spec: apisixv1alpha1.ConsumerSpec{ + Credentials: []apisixv1alpha1.Credential{{ + Type: "jwt-auth", + SecretRef: &apisixv1alpha1.SecretReference{ + Name: "jwt-secret", + Namespace: &ns, + }, + }, { + Type: "key-auth", + SecretRef: &apisixv1alpha1.SecretReference{ + Name: "key-secret", + }, + }}, + }, + } + + objs := []runtime.Object{ + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: "auth"}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "key-secret", Namespace: "default"}}, + } + + validator := buildConsumerValidator(t, objs...) + + warnings, err := validator.ValidateCreate(context.Background(), consumer) + require.NoError(t, err) + require.Empty(t, warnings) +}
