This is an automated email from the ASF dual-hosted git repository. ronething pushed a commit to branch feat/gatewayproxy_webhook in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
commit 63bed834a314fcb592648231051be9e5ec85162b Author: Ashing Zheng <[email protected]> AuthorDate: Fri Oct 10 17:09:18 2025 +0800 feat: support gateway proxy webhook Signed-off-by: Ashing Zheng <[email protected]> --- internal/webhook/v1/gatewayproxy_webhook.go | 127 +++++++++++++- internal/webhook/v1/gatewayproxy_webhook_test.go | 213 ++++++++++++++++++++++- 2 files changed, 337 insertions(+), 3 deletions(-) diff --git a/internal/webhook/v1/gatewayproxy_webhook.go b/internal/webhook/v1/gatewayproxy_webhook.go index 75bccea3..764e655b 100644 --- a/internal/webhook/v1/gatewayproxy_webhook.go +++ b/internal/webhook/v1/gatewayproxy_webhook.go @@ -18,6 +18,8 @@ package v1 import ( "context" "fmt" + "sort" + "strings" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -63,7 +65,12 @@ func (v *GatewayProxyCustomValidator) ValidateCreate(ctx context.Context, obj ru } gatewayProxyLog.Info("Validation for GatewayProxy upon creation", "name", gp.GetName(), "namespace", gp.GetNamespace()) - return v.collectWarnings(ctx, gp), nil + warnings := v.collectWarnings(ctx, gp) + if err := v.validateGatewayGroupConflict(ctx, gp); err != nil { + return warnings, err + } + + return warnings, nil } func (v *GatewayProxyCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { @@ -73,7 +80,12 @@ func (v *GatewayProxyCustomValidator) ValidateUpdate(ctx context.Context, oldObj } gatewayProxyLog.Info("Validation for GatewayProxy upon update", "name", gp.GetName(), "namespace", gp.GetNamespace()) - return v.collectWarnings(ctx, gp), nil + warnings := v.collectWarnings(ctx, gp) + if err := v.validateGatewayGroupConflict(ctx, gp); err != nil { + return warnings, err + } + + return warnings, nil } func (v *GatewayProxyCustomValidator) ValidateDelete(context.Context, runtime.Object) (admission.Warnings, error) { @@ -111,3 +123,114 @@ func (v *GatewayProxyCustomValidator) collectWarnings(ctx context.Context, gp *v return warnings } + +func (v *GatewayProxyCustomValidator) validateGatewayGroupConflict(ctx context.Context, gp *v1alpha1.GatewayProxy) error { + current := buildGatewayGroupConfig(gp) + if !current.readyForConflict() { + return nil + } + + var list v1alpha1.GatewayProxyList + if err := v.Client.List(ctx, &list); err != nil { + gatewayProxyLog.Error(err, "failed to list GatewayProxy objects for conflict detection") + return fmt.Errorf("failed to list existing GatewayProxy resources: %w", err) + } + + for _, other := range list.Items { + if other.GetNamespace() == gp.GetNamespace() && other.GetName() == gp.GetName() { + // skip self + continue + } + otherConfig := buildGatewayGroupConfig(&other) + if !otherConfig.readyForConflict() { + continue + } + if current.adminKeyKey != otherConfig.adminKeyKey { + continue + } + if current.serviceKey != "" && current.serviceKey == otherConfig.serviceKey { + return fmt.Errorf("gateway group conflict: GatewayProxy %s/%s and %s/%s both target %s while sharing %s", + gp.GetNamespace(), gp.GetName(), + other.GetNamespace(), other.GetName(), + current.serviceDescription, + current.adminKeyDescription, + ) + } + if len(current.endpoints) > 0 && len(otherConfig.endpoints) > 0 { + if overlap := current.endpointOverlap(otherConfig); len(overlap) > 0 { + return fmt.Errorf("gateway group conflict: GatewayProxy %s/%s and %s/%s both target control plane endpoints [%s] while sharing %s", + gp.GetNamespace(), gp.GetName(), + other.GetNamespace(), other.GetName(), + strings.Join(overlap, ", "), + current.adminKeyDescription, + ) + } + } + } + + return nil +} + +type gatewayGroupConfig struct { + adminKeyKey string + adminKeyDescription string + serviceKey string + serviceDescription string + endpoints map[string]struct{} + sortedEndpoints []string +} + +func buildGatewayGroupConfig(gp *v1alpha1.GatewayProxy) gatewayGroupConfig { + var cfg gatewayGroupConfig + + if gp == nil || gp.Spec.Provider == nil || gp.Spec.Provider.Type != v1alpha1.ProviderTypeControlPlane || gp.Spec.Provider.ControlPlane == nil { + return cfg + } + + cp := gp.Spec.Provider.ControlPlane + + if cp.Auth.AdminKey != nil { + if value := strings.TrimSpace(cp.Auth.AdminKey.Value); value != "" { + cfg.adminKeyKey = "value:" + value + cfg.adminKeyDescription = "the same inline AdminKey value" + } else if cp.Auth.AdminKey.ValueFrom != nil && cp.Auth.AdminKey.ValueFrom.SecretKeyRef != nil { + ref := cp.Auth.AdminKey.ValueFrom.SecretKeyRef + cfg.adminKeyKey = fmt.Sprintf("secret:%s/%s:%s", gp.GetNamespace(), ref.Name, ref.Key) + cfg.adminKeyDescription = fmt.Sprintf("AdminKey secret %s/%s key %s", gp.GetNamespace(), ref.Name, ref.Key) + } + } + + if cp.Service != nil && cp.Service.Name != "" { + cfg.serviceKey = fmt.Sprintf("service:%s/%s:%d", gp.GetNamespace(), cp.Service.Name, cp.Service.Port) + cfg.serviceDescription = fmt.Sprintf("Service %s/%s port %d", gp.GetNamespace(), cp.Service.Name, cp.Service.Port) + } + + if len(cp.Endpoints) > 0 { + cfg.endpoints = make(map[string]struct{}, len(cp.Endpoints)) + cfg.sortedEndpoints = append([]string(nil), cp.Endpoints...) + for _, endpoint := range cfg.sortedEndpoints { + cfg.endpoints[endpoint] = struct{}{} + } + sort.Strings(cfg.sortedEndpoints) + } + + return cfg +} + +func (c gatewayGroupConfig) readyForConflict() bool { + if c.adminKeyKey == "" { + return false + } + return c.serviceKey != "" || len(c.endpoints) > 0 +} + +func (c gatewayGroupConfig) endpointOverlap(other gatewayGroupConfig) []string { + var overlap []string + for endpoint := range c.endpoints { + if _, ok := other.endpoints[endpoint]; ok { + overlap = append(overlap, endpoint) + } + } + sort.Strings(overlap) + return overlap +} diff --git a/internal/webhook/v1/gatewayproxy_webhook_test.go b/internal/webhook/v1/gatewayproxy_webhook_test.go index c43253c1..82ab018b 100644 --- a/internal/webhook/v1/gatewayproxy_webhook_test.go +++ b/internal/webhook/v1/gatewayproxy_webhook_test.go @@ -54,7 +54,7 @@ func newGatewayProxy() *v1alpha1.GatewayProxy { Provider: &v1alpha1.GatewayProxyProvider{ Type: v1alpha1.ProviderTypeControlPlane, ControlPlane: &v1alpha1.ControlPlaneProvider{ - Service: &v1alpha1.ProviderService{Name: "control-plane"}, + Service: &v1alpha1.ProviderService{Name: "control-plane", Port: 9180}, Auth: v1alpha1.ControlPlaneAuth{ Type: v1alpha1.AuthTypeAdminKey, AdminKey: &v1alpha1.AdminKeyAuth{ @@ -72,6 +72,41 @@ func newGatewayProxy() *v1alpha1.GatewayProxy { } } +func newGatewayProxyWithEndpoints(name string, endpoints []string) *v1alpha1.GatewayProxy { + gp := newGatewayProxy() + gp.Name = name + gp.Spec.Provider.ControlPlane.Service = nil + gp.Spec.Provider.ControlPlane.Endpoints = endpoints + return gp +} + +func setInlineAdminKey(gp *v1alpha1.GatewayProxy, value string) { + if gp == nil || gp.Spec.Provider == nil || gp.Spec.Provider.ControlPlane == nil { + return + } + if gp.Spec.Provider.ControlPlane.Auth.AdminKey == nil { + gp.Spec.Provider.ControlPlane.Auth.AdminKey = &v1alpha1.AdminKeyAuth{} + } + gp.Spec.Provider.ControlPlane.Auth.AdminKey.Value = value + gp.Spec.Provider.ControlPlane.Auth.AdminKey.ValueFrom = nil +} + +func setSecretAdminKey(gp *v1alpha1.GatewayProxy, name, key string) { + if gp == nil || gp.Spec.Provider == nil || gp.Spec.Provider.ControlPlane == nil { + return + } + if gp.Spec.Provider.ControlPlane.Auth.AdminKey == nil { + gp.Spec.Provider.ControlPlane.Auth.AdminKey = &v1alpha1.AdminKeyAuth{} + } + gp.Spec.Provider.ControlPlane.Auth.AdminKey.Value = "" + gp.Spec.Provider.ControlPlane.Auth.AdminKey.ValueFrom = &v1alpha1.AdminKeyValueFrom{ + SecretKeyRef: &v1alpha1.SecretKeySelector{ + Name: name, + Key: key, + }, + } +} + func TestGatewayProxyValidator_MissingService(t *testing.T) { gp := newGatewayProxy() gp.Spec.Provider.ControlPlane.Auth.AdminKey = nil @@ -150,3 +185,179 @@ func TestGatewayProxyValidator_NoWarnings(t *testing.T) { require.NoError(t, err) require.Empty(t, warnings) } + +func TestGatewayProxyValidator_DetectsServiceConflict(t *testing.T) { + existing := newGatewayProxy() + existing.Name = "existing" + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "control-plane", + Namespace: "default", + }, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + + validator := buildGatewayProxyValidator(t, existing, service, secret) + + candidate := newGatewayProxy() + candidate.Name = "candidate" + + warnings, err := validator.ValidateCreate(context.Background(), candidate) + require.Error(t, err) + require.Len(t, warnings, 0) + require.Contains(t, err.Error(), "gateway group conflict") + require.Contains(t, err.Error(), "Service default/control-plane port 9180") + require.Contains(t, err.Error(), "AdminKey secret default/admin-key key token") +} + +func TestGatewayProxyValidator_DetectsEndpointConflict(t *testing.T) { + existing := newGatewayProxyWithEndpoints("existing", []string{"https://127.0.0.1:9443", "https://10.0.0.1:9443"}) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + validator := buildGatewayProxyValidator(t, existing, secret) + + candidate := newGatewayProxyWithEndpoints("candidate", []string{"https://10.0.0.1:9443", "https://127.0.0.1:9443"}) + + warnings, err := validator.ValidateCreate(context.Background(), candidate) + require.Error(t, err) + require.Len(t, warnings, 0) + require.Contains(t, err.Error(), "gateway group conflict") + require.Contains(t, err.Error(), "endpoints [https://10.0.0.1:9443, https://127.0.0.1:9443]") + require.Contains(t, err.Error(), "AdminKey secret default/admin-key key token") +} + +func TestGatewayProxyValidator_AllowsDistinctGatewayGroups(t *testing.T) { + existing := newGatewayProxyWithEndpoints("existing", []string{"https://127.0.0.1:9443"}) + 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, existing, secret, service) + + candidate := newGatewayProxy() + candidate.Name = "candidate" + candidate.Spec.Provider.ControlPlane.Service = &v1alpha1.ProviderService{ + Name: "control-plane", + Port: 9180, + } + + warnings, err := validator.ValidateCreate(context.Background(), candidate) + require.NoError(t, err) + require.Empty(t, warnings) +} + +func TestGatewayProxyValidator_AllowsServiceConflictWithDifferentAdminSecret(t *testing.T) { + existing := newGatewayProxy() + existing.Name = "existing" + + candidate := newGatewayProxy() + candidate.Name = "candidate" + setSecretAdminKey(candidate, "admin-key-alt", "token") + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "control-plane", + Namespace: "default", + }, + } + existingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + altSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key-alt", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + + validator := buildGatewayProxyValidator(t, existing, service, existingSecret, altSecret) + + warnings, err := validator.ValidateCreate(context.Background(), candidate) + require.NoError(t, err) + require.Empty(t, warnings) +} + +func TestGatewayProxyValidator_DetectsInlineAdminKeyConflict(t *testing.T) { + existing := newGatewayProxyWithEndpoints("existing", []string{"https://127.0.0.1:9443", "https://10.0.0.1:9443"}) + setInlineAdminKey(existing, "inline-cred") + + candidate := newGatewayProxyWithEndpoints("candidate", []string{"https://10.0.0.1:9443"}) + setInlineAdminKey(candidate, "inline-cred") + + validator := buildGatewayProxyValidator(t, existing) + + warnings, err := validator.ValidateCreate(context.Background(), candidate) + require.Error(t, err) + require.Len(t, warnings, 0) + require.Contains(t, err.Error(), "gateway group conflict") + require.Contains(t, err.Error(), "control plane endpoints [https://10.0.0.1:9443]") + require.Contains(t, err.Error(), "inline AdminKey value") +} + +func TestGatewayProxyValidator_AllowsEndpointOverlapWithDifferentAdminKey(t *testing.T) { + existing := newGatewayProxyWithEndpoints("existing", []string{"https://127.0.0.1:9443", "https://10.0.0.1:9443"}) + + candidate := newGatewayProxyWithEndpoints("candidate", []string{"https://10.0.0.1:9443", "https://192.168.0.1:9443"}) + setSecretAdminKey(candidate, "admin-key-alt", "token") + + existingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + altSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-key-alt", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("value"), + }, + } + + validator := buildGatewayProxyValidator(t, existing, existingSecret, altSecret) + + warnings, err := validator.ValidateCreate(context.Background(), candidate) + require.NoError(t, err) + require.Empty(t, warnings) +}
