diff --git a/pkg/ingress/config/ingress_config.go b/pkg/ingress/config/ingress_config.go index 69f6b1baf..3b3890423 100644 --- a/pkg/ingress/config/ingress_config.go +++ b/pkg/ingress/config/ingress_config.go @@ -151,6 +151,33 @@ type IngressConfig struct { clusterId cluster.ID httpsConfigMgr *cert.ConfigMgr + + // templateProcessor processes template variables in config + templateProcessor *TemplateProcessor + + // secretConfigMgr manages secret dependencies + secretConfigMgr *SecretConfigMgr +} + +// getSecretValue implements the getValue function for secret references +func (m *IngressConfig) getSecretValue(valueType, namespace, name, key string) (string, error) { + if valueType != "secret" { + return "", fmt.Errorf("unsupported value type: %s", valueType) + } + + m.mutex.RLock() + defer m.mutex.RUnlock() + + for _, controller := range m.remoteIngressControllers { + secret, err := controller.SecretLister().Secrets(namespace).Get(name) + if err == nil { + if value, exists := secret.Data[key]; exists { + return string(value), nil + } + return "", fmt.Errorf("key %s not found in secret %s/%s", key, namespace, name) + } + } + return "", fmt.Errorf("secret %s/%s not found", namespace, name) } func NewIngressConfig(localKubeClient kube.Client, xdsUpdater istiomodel.XDSUpdater, namespace string, options common.Options) *IngressConfig { @@ -171,6 +198,13 @@ func NewIngressConfig(localKubeClient kube.Client, xdsUpdater istiomodel.XDSUpda wasmPlugins: make(map[string]*extensions.WasmPlugin), http2rpcs: make(map[string]*higressv1.Http2Rpc), } + + // Initialize secret config manager + config.secretConfigMgr = NewSecretConfigMgr(xdsUpdater) + + // Initialize template processor with value getter function + config.templateProcessor = NewTemplateProcessor(config.getSecretValue, namespace, config.secretConfigMgr) + mcpbridgeController := mcpbridge.NewController(localKubeClient, options) mcpbridgeController.AddEventHandler(config.AddOrUpdateMcpBridge, config.DeleteMcpBridge) config.mcpbridgeController = mcpbridgeController @@ -228,6 +262,7 @@ func (m *IngressConfig) RegisterEventHandler(kind config.GroupVersionKind, f ist func (m *IngressConfig) AddLocalCluster(options common.Options) { secretController := secret.NewController(m.localKubeClient, options) secretController.AddEventHandler(m.ReflectSecretChanges) + secretController.AddEventHandler(m.secretConfigMgr.HandleSecretChange) var ingressController common.IngressController v1 := common.V1Available(m.localKubeClient) @@ -254,10 +289,24 @@ func (m *IngressConfig) List(typ config.GroupVersionKind, namespace string) []co var configs = make([]config.Config, 0) if configsFromIngress := m.listFromIngressControllers(typ, namespace); configsFromIngress != nil { + // Process templates for ingress configs + for i := range configsFromIngress { + if err := m.templateProcessor.ProcessConfig(&configsFromIngress[i]); err != nil { + IngressLog.Errorf("Failed to process template for config %s/%s: %v", + configsFromIngress[i].Namespace, configsFromIngress[i].Name, err) + } + } configs = append(configs, configsFromIngress...) } if configsFromGateway := m.listFromGatewayControllers(typ, namespace); configsFromGateway != nil { + // Process templates for gateway configs + for i := range configsFromGateway { + if err := m.templateProcessor.ProcessConfig(&configsFromGateway[i]); err != nil { + IngressLog.Errorf("Failed to process template for config %s/%s: %v", + configsFromGateway[i].Namespace, configsFromGateway[i].Name, err) + } + } configs = append(configs, configsFromGateway...) } @@ -987,7 +1036,6 @@ func (m *IngressConfig) convertIstioWasmPlugin(obj *higressext.WasmPlugin) (*ext return nil, nil } return result, nil - } func isBoolValueTrue(b *wrappers.BoolValue) bool { diff --git a/pkg/ingress/config/ingress_template.go b/pkg/ingress/config/ingress_template.go new file mode 100644 index 000000000..64cfdbbde --- /dev/null +++ b/pkg/ingress/config/ingress_template.go @@ -0,0 +1,119 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 config + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + . "github.com/alibaba/higress/pkg/ingress/log" + "google.golang.org/protobuf/proto" + "istio.io/istio/pkg/config" +) + +// TemplateProcessor handles template substitution in configs +type TemplateProcessor struct { + // getValue is a function that retrieves values by type, namespace, name and key + getValue func(valueType, namespace, name, key string) (string, error) + namespace string + secretConfigMgr *SecretConfigMgr +} + +// NewTemplateProcessor creates a new TemplateProcessor with the given value getter function +func NewTemplateProcessor(getValue func(valueType, namespace, name, key string) (string, error), namespace string, secretConfigMgr *SecretConfigMgr) *TemplateProcessor { + return &TemplateProcessor{ + getValue: getValue, + namespace: namespace, + secretConfigMgr: secretConfigMgr, + } +} + +// ProcessConfig processes a config and substitutes any template variables +func (p *TemplateProcessor) ProcessConfig(cfg *config.Config) error { + // Convert spec to JSON string to process substitutions + jsonBytes, err := json.Marshal(cfg.Spec) + if err != nil { + return fmt.Errorf("failed to marshal config spec: %v", err) + } + + configStr := string(jsonBytes) + // Find all value references in format: + // ${type.name.key} or ${type.namespace/name.key} + valueRegex := regexp.MustCompile(`\$\{([^.}]+)\.(?:([^/]+)/)?([^.}]+)\.([^}]+)\}`) + matches := valueRegex.FindAllStringSubmatch(configStr, -1) + // If there are no value references, return immediately + if len(matches) == 0 { + if p.secretConfigMgr != nil { + if err := p.secretConfigMgr.DeleteConfig(cfg); err != nil { + IngressLog.Errorf("failed to delete secret dependency: %v", err) + } + } + return nil + } + + foundSecretSource := false + IngressLog.Infof("start to apply config %s/%s with %d variables", cfg.Namespace, cfg.Name, len(matches)) + for _, match := range matches { + valueType := match[1] + var namespace, name, key string + if match[2] != "" { + // Format: ${type.namespace/name.key} + namespace = match[2] + } else { + // Format: ${type.name.key} - use default namespace + namespace = p.namespace + } + name = match[3] + key = match[4] + + // Get value using the provided getter function + value, err := p.getValue(valueType, namespace, name, key) + if err != nil { + return fmt.Errorf("failed to get %s value for %s/%s.%s: %v", valueType, namespace, name, key, err) + } + + // Add secret dependency if this is a secret reference + if valueType == "secret" && p.secretConfigMgr != nil { + foundSecretSource = true + secretKey := fmt.Sprintf("%s/%s", namespace, name) + if err := p.secretConfigMgr.AddConfig(secretKey, cfg); err != nil { + IngressLog.Errorf("failed to add secret dependency: %v", err) + } + } + // Replace placeholder with actual value + configStr = strings.Replace(configStr, match[0], value, 1) + } + + // Create a new instance of the same type as cfg.Spec + newSpec := proto.Clone(cfg.Spec.(proto.Message)) + if err := json.Unmarshal([]byte(configStr), newSpec); err != nil { + return fmt.Errorf("failed to unmarshal substituted config: %v", err) + } + cfg.Spec = newSpec + + // Delete secret dependency if no secret reference is found + if !foundSecretSource { + if p.secretConfigMgr != nil { + if err := p.secretConfigMgr.DeleteConfig(cfg); err != nil { + IngressLog.Errorf("failed to delete secret dependency: %v", err) + } + } + } + + IngressLog.Infof("end to process config %s/%s", cfg.Namespace, cfg.Name) + return nil +} diff --git a/pkg/ingress/config/ingress_template_test.go b/pkg/ingress/config/ingress_template_test.go new file mode 100644 index 000000000..300a19509 --- /dev/null +++ b/pkg/ingress/config/ingress_template_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" + extensions "istio.io/api/extensions/v1alpha1" + "istio.io/istio/pkg/config" + "istio.io/istio/pkg/config/schema/gvk" +) + +func TestTemplateProcessor_ProcessConfig(t *testing.T) { + // Create test values map + values := map[string]string{ + "secret.default/test-secret.api_key": "test-api-key", + "secret.default/test-secret.plugin_conf.timeout": "5000", + "secret.default/test-secret.plugin_conf.max_retries": "3", + "secret.higress-system/auth-secret.auth_config.type": "basic", + "secret.higress-system/auth-secret.auth_config.credentials": "base64-encoded", + } + + // Mock value getter function + getValue := func(valueType, namespace, name, key string) (string, error) { + fullKey := fmt.Sprintf("%s.%s/%s.%s", valueType, namespace, name, key) + fmt.Printf("Getting value for %s", fullKey) + if value, exists := values[fullKey]; exists { + return value, nil + } + return "", fmt.Errorf("value not found for %s", fullKey) + } + + // Create template processor + processor := NewTemplateProcessor(getValue, "higress-system", nil) + + tests := []struct { + name string + wasmPlugin *extensions.WasmPlugin + expected *extensions.WasmPlugin + expectError bool + }{ + { + name: "simple api key reference", + wasmPlugin: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "api_key": "${secret.default/test-secret.api_key}", + }), + }, + expected: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "api_key": "test-api-key", + }), + }, + expectError: false, + }, + { + name: "config with multiple fields", + wasmPlugin: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "config": map[string]interface{}{ + "timeout": "${secret.default/test-secret.plugin_conf.timeout}", + "max_retries": "${secret.default/test-secret.plugin_conf.max_retries}", + }, + }), + }, + expected: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "config": map[string]interface{}{ + "timeout": "5000", + "max_retries": "3", + }, + }), + }, + expectError: false, + }, + { + name: "auth config with default namespace", + wasmPlugin: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "auth": map[string]interface{}{ + "type": "${secret.auth-secret.auth_config.type}", + "credentials": "${secret.auth-secret.auth_config.credentials}", + }, + }), + }, + expected: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "auth": map[string]interface{}{ + "type": "basic", + "credentials": "base64-encoded", + }, + }), + }, + expectError: false, + }, + { + name: "non-existent secret", + wasmPlugin: &extensions.WasmPlugin{ + PluginName: "test-plugin", + PluginConfig: makeStructValue(t, map[string]interface{}{ + "api_key": "${secret.default/non-existent.api_key}", + }), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.WasmPlugin, + Name: "test-plugin", + Namespace: "default", + }, + Spec: tt.wasmPlugin, + } + + err := processor.ProcessConfig(cfg) + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + processedPlugin := cfg.Spec.(*extensions.WasmPlugin) + + // Compare plugin name + assert.Equal(t, tt.expected.PluginName, processedPlugin.PluginName) + + // Compare plugin configs + if tt.expected.PluginConfig != nil { + assert.NotNil(t, processedPlugin.PluginConfig) + assert.Equal(t, tt.expected.PluginConfig.AsMap(), processedPlugin.PluginConfig.AsMap()) + } + }) + } +} + +// Helper function to create structpb.Struct from map +func makeStructValue(t *testing.T, m map[string]interface{}) *structpb.Struct { + s, err := structpb.NewStruct(m) + assert.NoError(t, err, "Failed to create struct value") + return s +} diff --git a/pkg/ingress/config/secret_config_mgr.go b/pkg/ingress/config/secret_config_mgr.go new file mode 100644 index 000000000..547c2b5d5 --- /dev/null +++ b/pkg/ingress/config/secret_config_mgr.go @@ -0,0 +1,157 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 config + +import ( + "fmt" + "sync" + + "github.com/alibaba/higress/pkg/ingress/kube/util" + . "github.com/alibaba/higress/pkg/ingress/log" + istiomodel "istio.io/istio/pilot/pkg/model" + "istio.io/istio/pkg/config" + "istio.io/istio/pkg/config/schema/kind" + "istio.io/istio/pkg/util/sets" +) + +// toConfigKey converts config.Config to istiomodel.ConfigKey +func toConfigKey(cfg *config.Config) (istiomodel.ConfigKey, error) { + return istiomodel.ConfigKey{ + Kind: kind.MustFromGVK(cfg.GroupVersionKind), + Name: cfg.Name, + Namespace: cfg.Namespace, + }, nil +} + +// SecretConfigMgr maintains the mapping between secrets and configs +type SecretConfigMgr struct { + mutex sync.RWMutex + + // configSet tracks all configs that have been added + // key format: namespace/name + configSet sets.Set[string] + + // secretToConfigs maps secret key to dependent configs + // key format: namespace/name + secretToConfigs map[string]sets.Set[istiomodel.ConfigKey] + + // watchedSecrets tracks which secrets are being watched + watchedSecrets sets.Set[string] + + // xdsUpdater is used to push config updates + xdsUpdater istiomodel.XDSUpdater +} + +// NewSecretConfigMgr creates a new SecretConfigMgr +func NewSecretConfigMgr(xdsUpdater istiomodel.XDSUpdater) *SecretConfigMgr { + return &SecretConfigMgr{ + secretToConfigs: make(map[string]sets.Set[istiomodel.ConfigKey]), + watchedSecrets: sets.New[string](), + configSet: sets.New[string](), + xdsUpdater: xdsUpdater, + } +} + +// AddConfig adds a config and its secret dependencies +func (m *SecretConfigMgr) AddConfig(secretKey string, cfg *config.Config) error { + configKey, _ := toConfigKey(cfg) + + m.mutex.Lock() + defer m.mutex.Unlock() + + configId := fmt.Sprintf("%s/%s", cfg.Namespace, cfg.Name) + m.configSet.Insert(configId) + + if configs, exists := m.secretToConfigs[secretKey]; exists { + configs.Insert(configKey) + } else { + m.secretToConfigs[secretKey] = sets.New(configKey) + } + + // Add to watched secrets + m.watchedSecrets.Insert(secretKey) + return nil +} + +// DeleteConfig removes a config from all secret dependencies +func (m *SecretConfigMgr) DeleteConfig(cfg *config.Config) error { + configKey, _ := toConfigKey(cfg) + m.mutex.Lock() + defer m.mutex.Unlock() + + configId := fmt.Sprintf("%s/%s", cfg.Namespace, cfg.Name) + if !m.configSet.Contains(configId) { + return nil + } + + removeKeys := make([]string, 0) + // Find and remove the config from all secrets + for secretKey, configs := range m.secretToConfigs { + if configs.Contains(configKey) { + configs.Delete(configKey) + // If no more configs depend on this secret, remove it + if configs.Len() == 0 { + removeKeys = append(removeKeys, secretKey) + } + } + } + + // Remove the secrets from the secretToConfigs map + for _, secretKey := range removeKeys { + delete(m.secretToConfigs, secretKey) + m.watchedSecrets.Delete(secretKey) + } + // Remove the config from the config set + m.configSet.Delete(configId) + return nil +} + +// GetConfigsForSecret returns all configs that depend on the given secret +func (m *SecretConfigMgr) GetConfigsForSecret(secretKey string) []istiomodel.ConfigKey { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if configs, exists := m.secretToConfigs[secretKey]; exists { + return configs.UnsortedList() + } + return nil +} + +// IsSecretWatched checks if a secret is being watched +func (m *SecretConfigMgr) IsSecretWatched(secretKey string) bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + return m.watchedSecrets.Contains(secretKey) +} + +// HandleSecretChange handles secret changes and updates affected configs +func (m *SecretConfigMgr) HandleSecretChange(name util.ClusterNamespacedName) { + secretKey := fmt.Sprintf("%s/%s", name.Namespace, name.Name) + // Check if this secret is being watched + if !m.IsSecretWatched(secretKey) { + return + } + + // Get affected configs + configKeys := m.GetConfigsForSecret(secretKey) + if len(configKeys) == 0 { + return + } + IngressLog.Infof("SecretConfigMgr Secret %s changed, updating %d dependent configs and push", secretKey, len(configKeys)) + m.xdsUpdater.ConfigUpdate(&istiomodel.PushRequest{ + Full: true, + Reason: istiomodel.NewReasonStats(istiomodel.SecretTrigger), + }) +} diff --git a/pkg/ingress/config/secret_config_mgr_test.go b/pkg/ingress/config/secret_config_mgr_test.go new file mode 100644 index 000000000..6f31c737f --- /dev/null +++ b/pkg/ingress/config/secret_config_mgr_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 config + +import ( + "k8s.io/apimachinery/pkg/types" + "testing" + + "github.com/alibaba/higress/pkg/ingress/kube/util" + "github.com/stretchr/testify/assert" + istiomodel "istio.io/istio/pilot/pkg/model" + "istio.io/istio/pkg/cluster" + "istio.io/istio/pkg/config" + "istio.io/istio/pkg/config/schema/gvk" + "istio.io/istio/pkg/config/schema/kind" +) + +type mockXdsUpdater struct { + lastPushRequest *istiomodel.PushRequest +} + +func (m *mockXdsUpdater) EDSUpdate(shard istiomodel.ShardKey, hostname string, namespace string, entry []*istiomodel.IstioEndpoint) { + //TODO implement me + panic("implement me") +} + +func (m *mockXdsUpdater) EDSCacheUpdate(shard istiomodel.ShardKey, hostname string, namespace string, entry []*istiomodel.IstioEndpoint) { + //TODO implement me + panic("implement me") +} + +func (m *mockXdsUpdater) SvcUpdate(shard istiomodel.ShardKey, hostname string, namespace string, event istiomodel.Event) { + //TODO implement me + panic("implement me") +} + +func (m *mockXdsUpdater) ProxyUpdate(clusterID cluster.ID, ip string) { + //TODO implement me + panic("implement me") +} + +func (m *mockXdsUpdater) RemoveShard(shardKey istiomodel.ShardKey) { + //TODO implement me + panic("implement me") +} + +func (m *mockXdsUpdater) ConfigUpdate(req *istiomodel.PushRequest) { + m.lastPushRequest = req +} + +func TestSecretConfigMgr(t *testing.T) { + updater := &mockXdsUpdater{} + mgr := NewSecretConfigMgr(updater) + + // Test AddConfig + t.Run("AddConfig", func(t *testing.T) { + wasmPlugin := &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.WasmPlugin, + Name: "test-plugin", + Namespace: "default", + }, + } + + err := mgr.AddConfig("default/test-secret", wasmPlugin) + assert.NoError(t, err) + assert.True(t, mgr.IsSecretWatched("default/test-secret")) + + configs := mgr.GetConfigsForSecret("default/test-secret") + assert.Len(t, configs, 1) + assert.Equal(t, kind.WasmPlugin, configs[0].Kind) + assert.Equal(t, "test-plugin", configs[0].Name) + assert.Equal(t, "default", configs[0].Namespace) + }) + + // Test DeleteConfig + t.Run("DeleteConfig", func(t *testing.T) { + wasmPlugin := &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.WasmPlugin, + Name: "test-plugin", + Namespace: "default", + }, + } + + err := mgr.DeleteConfig(wasmPlugin) + assert.NoError(t, err) + assert.False(t, mgr.IsSecretWatched("default/test-secret")) + assert.Empty(t, mgr.GetConfigsForSecret("default/test-secret")) + }) + + // Test HandleSecretChange + t.Run("HandleSecretChange", func(t *testing.T) { + // Add a config first + wasmPlugin := &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.WasmPlugin, + Name: "test-plugin", + Namespace: "default", + }, + } + err := mgr.AddConfig("default/test-secret", wasmPlugin) + assert.NoError(t, err) + + // Test secret change + secretName := util.ClusterNamespacedName{ + NamespacedName: types.NamespacedName{ + Name: "test-secret", + Namespace: "default", + }, + } + + mgr.HandleSecretChange(secretName) + assert.NotNil(t, updater.lastPushRequest) + assert.True(t, updater.lastPushRequest.Full) + }) + + // Test full push for secret update + t.Run("FullPushForSecretUpdate", func(t *testing.T) { + // Add a secret config + secretConfig := &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.Secret, + Name: "test-secret", + Namespace: "default", + }, + } + err := mgr.AddConfig("default/test-secret", secretConfig) + assert.NoError(t, err) + + // Update the secret + secretName := util.ClusterNamespacedName{ + NamespacedName: types.NamespacedName{ + Name: "test-secret", + Namespace: "default", + }, + } + + mgr.HandleSecretChange(secretName) + assert.NotNil(t, updater.lastPushRequest) + assert.True(t, updater.lastPushRequest.Full) + }) +} diff --git a/test/e2e/conformance/tests/go-wasm-basic-auth-template.go b/test/e2e/conformance/tests/go-wasm-basic-auth-template.go new file mode 100644 index 000000000..8d0540a09 --- /dev/null +++ b/test/e2e/conformance/tests/go-wasm-basic-auth-template.go @@ -0,0 +1,192 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 tests + +import ( + "github.com/alibaba/higress/test/e2e/conformance/utils/kubernetes" + "testing" + + "github.com/alibaba/higress/test/e2e/conformance/utils/http" + "github.com/alibaba/higress/test/e2e/conformance/utils/suite" +) + +func init() { + Register(WasmPluginsBasicAuthTemplate) +} + +var WasmPluginsBasicAuthTemplate = suite.ConformanceTest{ + ShortName: "WasmPluginsBasicAuthTemplate", + Description: "The Ingress in the higress-conformance-infra namespace test the basic-auth WASM plugin.", + Manifests: []string{"tests/go-wasm-basic-auth-template.yaml"}, + Features: []suite.SupportedFeature{suite.WASMGoConformanceFeature}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testcases := []http.Assertion{ + { + Meta: http.AssertionMeta{ + TestCaseName: "case 1: Successful authentication", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46MTIzNDU2"}, // base64("admin:123456") + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"X-Mse-Consumer": "consumer1"}, + }, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 2: No Basic Authentication information found", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 3: Invalid username and/or password", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46cXdlcg=="}, // base64("admin:qwer") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 4: Unauthorized consumer", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic Z3Vlc3Q6YWJj"}, // base64("guest:abc") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 403, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + } + + testcases2 := []http.Assertion{ + { + Meta: http.AssertionMeta{ + TestCaseName: "case 5: Invalid username and/or password", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46MTIzNDU2"}, // base64("admin:123456") + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"X-Mse-Consumer": "consumer1"}, + }, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 6: Successful authentication", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46cXdlcg=="}, // base64("admin:qwer") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + } + t.Run("WasmPlugins basic-auth", func(t *testing.T) { + for _, testcase := range testcases { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + err := kubernetes.ApplySecret(t, suite.Client, "higress-conformance-infra", "auth-secret", "auth.credential1", "admin:qwer") + if err != nil { + t.Fatalf("can't apply secret %s in namespace %s for data key %s", "auth-secret", "higress-conformance-infra", "auth.credential1") + } + for _, testcase := range testcases2 { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + }) + }, +} diff --git a/test/e2e/conformance/tests/go-wasm-basic-auth-template.yaml b/test/e2e/conformance/tests/go-wasm-basic-auth-template.yaml new file mode 100644 index 000000000..0ea0e3264 --- /dev/null +++ b/test/e2e/conformance/tests/go-wasm-basic-auth-template.yaml @@ -0,0 +1,77 @@ +# Copyright (c) 2022 Alibaba Group Holding Ltd. +# +# 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. + + +apiVersion: v1 +kind: Secret +metadata: + name: auth-secret + namespace: higress-conformance-infra +type: Opaque +stringData: + auth.credential1: "admin:123456" + +--- +apiVersion: v1 +kind: Secret +metadata: + name: auth-secret + namespace: higress-system +type: Opaque +stringData: + auth.credential2: "guest:abc" + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + name: wasmplugin-basic-auth + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/foo" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: basic-auth + namespace: higress-system +spec: + defaultConfig: + consumers: + - credential: ${secret.higress-conformance-infra/auth-secret.auth.credential1} + name: consumer1 + - credential: ${secret.auth-secret.auth.credential2} + name: consumer2 + global_auth: false + defaultConfigDisable: false + matchRules: + - config: + allow: + - consumer1 + configDisable: false + ingress: + - higress-conformance-infra/wasmplugin-basic-auth + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/go-basic-auth:1.0.0 diff --git a/test/e2e/conformance/utils/kubernetes/helpers.go b/test/e2e/conformance/utils/kubernetes/helpers.go index f423e1d68..ed8325ce4 100644 --- a/test/e2e/conformance/utils/kubernetes/helpers.go +++ b/test/e2e/conformance/utils/kubernetes/helpers.go @@ -144,3 +144,15 @@ func ApplyConfigmapDataWithYaml(t *testing.T, c client.Client, namespace string, t.Logf("🏗 Updating %s %s", name, namespace) return c.Update(ctx, cm) } + +func ApplySecret(t *testing.T, c client.Client, namespace string, name string, key string, val string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cm := &v1.Secret{} + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, cm); err != nil { + return err + } + cm.Data[key] = []byte(val) + t.Logf("🏗 Updating Secret %s %s", name, namespace) + return c.Update(ctx, cm) +}