From f3d48ce6a4f5696c7d1cf419fe2e386f3b5455f2 Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:57:48 +0800 Subject: [PATCH 1/2] Tag filter support (#56) * tag filter support * update dependency * update * update * dedup tagFilters * rename * update * update --- .../azureappconfiguration.go | 17 +- .../azureappconfiguration_test.go | 206 +++++++++++++++++- azureappconfiguration/client_manager.go | 2 +- azureappconfiguration/failover_test.go | 2 +- azureappconfiguration/go.mod | 2 +- azureappconfiguration/go.sum | 4 +- .../internal/tracing/tracing.go | 4 +- azureappconfiguration/options.go | 47 +++- azureappconfiguration/refresh_test.go | 2 +- azureappconfiguration/settings_client.go | 22 +- azureappconfiguration/snapshot_test.go | 2 +- azureappconfiguration/utils.go | 29 ++- azureappconfiguration/utils_test.go | 108 +++++++++ 13 files changed, 415 insertions(+), 32 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index f34886e..82cdc03 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -33,7 +33,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tree" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" ) @@ -55,8 +55,8 @@ type AzureAppConfiguration struct { // Settings used for refresh scenarios sentinelETags map[WatchedSetting]*azcore.ETag watchAll bool - kvETags map[Selector][]*azcore.ETag - ffETags map[Selector][]*azcore.ETag + kvETags map[comparableSelector][]*azcore.ETag + ffETags map[comparableSelector][]*azcore.ETag keyVaultRefs map[string]string // unversioned Key Vault references kvRefreshTimer refresh.Condition secretRefreshTimer refresh.Condition @@ -121,7 +121,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) - azappcfg.kvETags = make(map[Selector][]*azcore.ETag) + azappcfg.kvETags = make(map[comparableSelector][]*azcore.ETag) if len(options.RefreshOptions.WatchedSettings) == 0 { azappcfg.watchAll = true } @@ -137,7 +137,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.ffSelectors = getFeatureFlagSelectors(deduplicateSelectors(options.FeatureFlagOptions.Selectors)) if options.FeatureFlagOptions.RefreshOptions.Enabled { azappcfg.ffRefreshTimer = refresh.NewTimer(options.FeatureFlagOptions.RefreshOptions.Interval) - azappcfg.ffETags = make(map[Selector][]*azcore.ETag) + azappcfg.ffETags = make(map[comparableSelector][]*azcore.ETag) } } @@ -759,7 +759,7 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Create a map to track unique selectors - seen := make(map[Selector]struct{}) + seen := make(map[comparableSelector]struct{}) var result []Selector // Process the selectors in reverse order to maintain the behavior @@ -771,8 +771,9 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Check if we've seen this selector before - if _, exists := seen[selectors[i]]; !exists { - seen[selectors[i]] = struct{}{} + key := selectors[i].comparableKey() + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} result = append(result, selectors[i]) } } diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index f68d0ba..681b622 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -16,7 +16,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/fm" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -128,7 +128,7 @@ func TestLoadFeatureFlags_Success(t *testing.T) { {Key: toPtr(".appconfig.featureflag/Beta"), Value: &value1, ContentType: toPtr(featureFlagContentType)}, {Key: toPtr(".appconfig.featureflag/Alpha"), Value: &value2, ContentType: toPtr(featureFlagContentType)}, }, - pageETags: map[Selector][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1545,7 +1545,7 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { ContentType: toPtr(featureFlagContentType), }, }, - pageETags: map[Selector][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1601,3 +1601,203 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { // Verify max variants is included assert.Contains(t, correlationCtx, tracing.FFMaxVariantsKey+"=3") } + +func TestLoadKeyValues_WithTagFilter(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + // Create mock settings with different tags + value1 := "value1" + value3 := "value3" + value4 := "value4" + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr("app:key1"), + Value: &value1, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + }, + }, + { + Key: toPtr("app:key3"), + Value: &value3, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("frontend"), + }, + }, + { + Key: toPtr("app:key4"), + Value: &value4, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + "feature": toPtr("new"), + }, + }, + }, + pageETags: map[comparableSelector][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Test with single tag filter + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: []Selector{ + { + KeyFilter: "*", + TagFilters: []string{"env=production"}, + }, + }, + keyValues: make(map[string]any), + } + + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Should load keys with env=production tag (key1, key3, key4) + assert.Equal(t, &value1, azappcfg.keyValues["app:key1"]) + assert.Equal(t, &value3, azappcfg.keyValues["app:key3"]) + assert.Equal(t, &value4, azappcfg.keyValues["app:key4"]) + assert.NotContains(t, azappcfg.keyValues, "app:key2") // staging env, should be filtered out +} + +func TestLoadKeyValues_WithMultipleTagFilters(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + value1 := "value1" + value4 := "value4" + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr("app:key1"), + Value: &value1, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + }, + }, + { + Key: toPtr("app:key4"), + Value: &value4, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + "feature": toPtr("new"), + }, + }, + }, + pageETags: map[comparableSelector][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Test with multiple tag filters (must match ALL) + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: []Selector{ + { + KeyFilter: "*", + TagFilters: []string{"env=production", "team=backend"}, + }, + }, + keyValues: make(map[string]any), + } + + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Should load only keys that match BOTH env=production AND team=backend (key1, key4) + assert.Equal(t, &value1, azappcfg.keyValues["app:key1"]) + assert.Equal(t, &value4, azappcfg.keyValues["app:key4"]) +} + +func TestSelectorComparableKey_WithTagFilter(t *testing.T) { + // Test that selectors with same TagFilter (but different order) produce the same comparable key + selector1 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{"env=production", "team=backend"}, + } + + selector2 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{"team=backend", "env=production"}, // Different order + } + + key1 := selector1.comparableKey() + key2 := selector2.comparableKey() + + // Should produce the same comparable key due to sorting + assert.Equal(t, key1, key2) + assert.Equal(t, `["env=production","team=backend"]`, key1.TagFilters) + assert.Equal(t, `["env=production","team=backend"]`, key2.TagFilters) +} + +func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) { + // Test that selectors handle special characters in tag values correctly + selector := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{ + `env=prod,staging`, // Comma in value + `description="test,with,quotes"`, // Quotes and commas + `path=c:\windows\system32`, // Backslashes + `json={"key":"value"}`, // JSON in value + }, + } + + key := selector.comparableKey() + + // Verify JSON encoding handles all special characters properly + expected := `["description=\"test,with,quotes\"","env=prod,staging","json={\"key\":\"value\"}","path=c:\\windows\\system32"]` + assert.Equal(t, expected, key.TagFilters) +} + +func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { + // Test empty TagFilter + selector1 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{}, + } + + key1 := selector1.comparableKey() + assert.Equal(t, "", key1.TagFilters) + + // Test nil TagFilter (should be handled the same as empty) + selector2 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: nil, + } + + key2 := selector2.comparableKey() + assert.Equal(t, "", key2.TagFilters) +} + +func TestSelectorComparableKey_Deterministic(t *testing.T) { + // Test that the same selector always produces the same key + selector := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{"z=last", "a=first", "m=middle"}, + } + + key1 := selector.comparableKey() + key2 := selector.comparableKey() + + assert.Equal(t, key1, key2) + assert.Equal(t, `["a=first","m=middle","z=last"]`, key1.TagFilters) // Should be sorted +} diff --git a/azureappconfiguration/client_manager.go b/azureappconfiguration/client_manager.go index 1b125fc..4ffdb97 100644 --- a/azureappconfiguration/client_manager.go +++ b/azureappconfiguration/client_manager.go @@ -17,7 +17,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) // configurationClientManager handles creation and management of app configuration clients diff --git a/azureappconfiguration/failover_test.go b/azureappconfiguration/failover_test.go index 2b06e05..c389cc5 100644 --- a/azureappconfiguration/failover_test.go +++ b/azureappconfiguration/failover_test.go @@ -14,7 +14,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index 260945b..def7c87 100644 --- a/azureappconfiguration/go.mod +++ b/azureappconfiguration/go.mod @@ -2,7 +2,7 @@ module github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration go 1.24.0 -require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 +require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index af0f2ef..292ddcf 100644 --- a/azureappconfiguration/go.sum +++ b/azureappconfiguration/go.sum @@ -2,8 +2,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HR github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 h1:uU4FujKFQAz31AbWOO3INV9qfIanHeIUSsGhRlcJJmg= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0/go.mod h1:qr3M3Oy6V98VR0c5tCHKUpaeJTRQh6KYzJewRtFWqfc= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 h1:K7LqZL3VW+DElZhW+5tY/cp2RRFrB3W45WUG/9fhhls= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0/go.mod h1:4IPby+BYf0rPMnMur/mNtowysFd4NoEW5U1vhrkhARA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index ff4cb75..2c30ccf 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -45,8 +45,8 @@ const ( LoadBalancingEnabledTag = "LB" // Feature flag usage tracing - FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" - FMGoVerKey = "FMGoVer" + FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" + FMGoVerKey = "FMGoVer" FeatureFilterTypeKey = "Filter" CustomFilterKey = "CSTM" TimeWindowFilterKey = "TIME" diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index d1f0533..937a72c 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -5,11 +5,13 @@ package azureappconfiguration import ( "context" + "encoding/json" "net/url" + "sort" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) // Options contains optional parameters to configure the behavior of an Azure App Configuration provider. @@ -79,6 +81,49 @@ type Selector struct { // SnapshotName specifies the name of the snapshot to retrieve. // If SnapshotName is used in a selector, no key and label filter should be used for it. Otherwise, an error will be returned. SnapshotName string + + // TagFilters specifies which tags to retrieve from Azure App Configuration. + // Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + // Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + TagFilters []string +} + +// comparableKey returns a comparable representation of the Selector that can be used as a map key. +// This method creates a deterministic string representation by sorting the TagFilter slice. +func (s Selector) comparableKey() comparableSelector { + cs := comparableSelector{ + KeyFilter: s.KeyFilter, + LabelFilter: s.LabelFilter, + SnapshotName: s.SnapshotName, + } + + if len(s.TagFilters) > 0 { + // Deduplicate TagFilter + unique := make(map[string]struct{}, len(s.TagFilters)) + var tagFilter []string + for _, tag := range s.TagFilters { + if _, exists := unique[tag]; !exists { + unique[tag] = struct{}{} + tagFilter = append(tagFilter, tag) + } + } + sort.Strings(tagFilter) + + // Use JSON encoding for robust serialization that handles all special characters + tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail + cs.TagFilters = string(tagFilterJSON) + } + + return cs +} + +// comparableSelector is a comparable version of Selector that can be used as a map key. +// It represents the same selector information but with TagFilter as a sorted, JSON-encoded string. +type comparableSelector struct { + KeyFilter string + LabelFilter string + SnapshotName string + TagFilters string // Sorted, JSON-encoded representation of the original TagFilter slice } // KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index b0fe243..76274a0 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -14,7 +14,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/refresh" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index ca3bbc9..d269322 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -5,6 +5,7 @@ package azureappconfiguration import ( "context" + "encoding/json" "errors" "fmt" "log" @@ -13,13 +14,13 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) type settingsResponse struct { settings []azappconfig.Setting watchedETags map[WatchedSetting]*azcore.ETag - pageETags map[Selector][]*azcore.ETag + pageETags map[comparableSelector][]*azcore.ETag } type selectorSettingsClient struct { @@ -36,7 +37,7 @@ type watchedSettingClient struct { } type pageETagsClient struct { - pageETags map[Selector][]*azcore.ETag + pageETags map[comparableSelector][]*azcore.ETag client *azappconfig.Client tracingOptions tracing.Options } @@ -61,12 +62,13 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } settings := make([]azappconfig.Setting, 0) - pageETags := make(map[Selector][]*azcore.ETag) + pageETags := make(map[comparableSelector][]*azcore.ETag) for _, filter := range s.selectors { if filter.SnapshotName == "" { selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), LabelFilter: to.Ptr(filter.LabelFilter), + TagsFilter: filter.TagFilters, Fields: azappconfig.AllSettingFields(), } @@ -82,7 +84,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } } - pageETags[filter] = eTags + pageETags[filter.comparableKey()] = eTags } else { snapshot, err := s.client.GetSnapshot(ctx, filter.SnapshotName, nil) if err != nil { @@ -167,10 +169,6 @@ func (c *watchedSettingClient) checkIfETagChanged(ctx context.Context) (bool, er } func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) { - if c.tracingOptions.Enabled { - ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, c.tracingOptions)) - } - for selector, pageETags := range c.pageETags { s := azappconfig.SettingSelector{ KeyFilter: to.Ptr(selector.KeyFilter), @@ -178,6 +176,12 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) Fields: azappconfig.AllSettingFields(), } + tagFilters := make([]string, 0) + if selector.TagFilters != "" { + json.Unmarshal([]byte(selector.TagFilters), &tagFilters) + s.TagsFilter = tagFilters + } + conditions := make([]azcore.MatchConditions, 0) for _, eTag := range pageETags { conditions = append(conditions, azcore.MatchConditions{IfNoneMatch: eTag}) diff --git a/azureappconfiguration/snapshot_test.go b/azureappconfiguration/snapshot_test.go index 4506e91..d8ed62e 100644 --- a/azureappconfiguration/snapshot_test.go +++ b/azureappconfiguration/snapshot_test.go @@ -9,7 +9,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 6bee613..aede1ba 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -77,8 +77,8 @@ func verifyOptions(options *Options) error { func verifySelectors(selectors []Selector) error { for _, selector := range selectors { if selector.SnapshotName != "" { - if selector.KeyFilter != "" || selector.LabelFilter != "" { - return fmt.Errorf("key and label filters should not be used if snapshot name is provided") + if selector.KeyFilter != "" || selector.LabelFilter != "" || len(selector.TagFilters) > 0 { + return fmt.Errorf("key, label and tag filters should not be used if snapshot name is provided") } } else { if selector.KeyFilter == "" { @@ -88,6 +88,31 @@ func verifySelectors(selectors []Selector) error { if strings.Contains(selector.LabelFilter, "*") || strings.Contains(selector.LabelFilter, ",") { return fmt.Errorf("label filter cannot contain '*' or ','") } + + if err := validateTagFilters(selector.TagFilters); err != nil { + return err + } + } + } + + return nil +} + +// validateTagFilters validates that each tag filter follows the required format "tagName=tagValue" +// and ensures no more than 5 tag filters are provided. +func validateTagFilters(tagFilters []string) error { + if len(tagFilters) > 5 { + return fmt.Errorf("up to 5 tag filters can be provided, got %d", len(tagFilters)) + } + + for _, tagFilter := range tagFilters { + if tagFilter == "" { + return fmt.Errorf("tag filter cannot be empty") + } + + parts := strings.Split(tagFilter, "=") + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid tag filter: %s. Tag filter must follow the format \"tagName=tagValue\"", tagFilter) } } diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index 160b7f7..dc1c560 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -186,6 +186,34 @@ func TestVerifySelectors(t *testing.T) { }, expectedError: true, }, + { + name: "valid tag filter", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"environment=production", "team=backend"}}, + }, + expectedError: false, + }, + { + name: "invalid tag filter format", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"invalid_format"}}, + }, + expectedError: true, + }, + { + name: "too many tag filters", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"tag1=val1", "tag2=val2", "tag3=val3", "tag4=val4", "tag5=val5", "tag6=val6"}}, + }, + expectedError: true, + }, + { + name: "tag filter with snapshot (should fail)", + selectors: []Selector{ + {SnapshotName: "my-snapshot", TagFilters: []string{"environment=production"}}, + }, + expectedError: true, + }, } for _, test := range tests { @@ -200,6 +228,86 @@ func TestVerifySelectors(t *testing.T) { } } +func TestValidateTagFilters(t *testing.T) { + tests := []struct { + name string + tagFilters []string + expectedError bool + errorContains string + }{ + { + name: "empty tag filters", + tagFilters: []string{}, + expectedError: false, + }, + { + name: "valid single tag filter", + tagFilters: []string{"environment=production"}, + expectedError: false, + }, + { + name: "valid multiple tag filters", + tagFilters: []string{"environment=production", "team=backend", "version=1.0"}, + expectedError: false, + }, + { + name: "valid tag filter with numbers and special chars", + tagFilters: []string{"tag=value\\,with\\,commas"}, + expectedError: false, + }, + { + name: "too many tag filters (more than 5)", + tagFilters: []string{"tag1=value1", "tag2=value2", "tag3=value3", "tag4=value4", "tag5=value5", "tag6=value6"}, + expectedError: true, + errorContains: "up to 5 tag filters can be provided", + }, + { + name: "empty tag filter string", + tagFilters: []string{""}, + expectedError: true, + errorContains: "tag filter cannot be empty", + }, + { + name: "invalid format - no equals sign", + tagFilters: []string{"environmentproduction"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "invalid format - empty tag name", + tagFilters: []string{"=production"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "invalid format - only equals sign", + tagFilters: []string{"="}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "mixed valid and invalid tag filters", + tagFilters: []string{"environment=production", "invalid_format"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTagFilters(test.tagFilters) + if test.expectedError { + assert.Error(t, err) + if test.errorContains != "" { + assert.Contains(t, err.Error(), test.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + func TestReverse(t *testing.T) { tests := []struct { name string From e2eb2cea72a32c83411a7017fdc55341a8ca41dd Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:41:42 +0800 Subject: [PATCH 2/2] dependency update (#58) --- azureappconfiguration/go.mod | 10 +++++----- azureappconfiguration/go.sum | 28 ++++++++++++++-------------- azureappconfiguration/version.go | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index def7c87..2035726 100644 --- a/azureappconfiguration/go.mod +++ b/azureappconfiguration/go.mod @@ -13,12 +13,12 @@ require ( ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 github.com/go-viper/mapstructure/v2 v2.4.0 - github.com/stretchr/testify v1.10.0 - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 - golang.org/x/text v0.27.0 // indirect + github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.18.0 + golang.org/x/text v0.31.0 // indirect ) diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index 292ddcf..93a5631 100644 --- a/azureappconfiguration/go.sum +++ b/azureappconfiguration/go.sum @@ -1,5 +1,5 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 h1:K7LqZL3VW+DElZhW+5tY/cp2RRFrB3W45WUG/9fhhls= @@ -34,18 +34,18 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/azureappconfiguration/version.go b/azureappconfiguration/version.go index 5329122..541136f 100644 --- a/azureappconfiguration/version.go +++ b/azureappconfiguration/version.go @@ -5,5 +5,5 @@ package azureappconfiguration const ( moduleName = "azcfg-go" - moduleVersion = "1.3.0" + moduleVersion = "1.4.0" )