Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions azureappconfiguration/azureappconfiguration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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[selectorKey][]*azcore.ETag
ffETags map[selectorKey][]*azcore.ETag
keyVaultRefs map[string]string // unversioned Key Vault references
kvRefreshTimer refresh.Condition
secretRefreshTimer refresh.Condition
Expand Down Expand Up @@ -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[selectorKey][]*azcore.ETag)
if len(options.RefreshOptions.WatchedSettings) == 0 {
azappcfg.watchAll = true
}
Expand All @@ -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[selectorKey][]*azcore.ETag)
}
}

Expand Down Expand Up @@ -759,7 +759,7 @@ func deduplicateSelectors(selectors []Selector) []Selector {
}

// Create a map to track unique selectors
seen := make(map[Selector]struct{})
seen := make(map[selectorKey]struct{})
var result []Selector

// Process the selectors in reverse order to maintain the behavior
Expand All @@ -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])
}
}
Expand Down
206 changes: 203 additions & 3 deletions azureappconfiguration/azureappconfiguration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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[selectorKey][]*azcore.ETag{},
}

mockClient.On("getSettings", ctx).Return(mockResponse, nil)
Expand Down Expand Up @@ -1545,7 +1545,7 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) {
ContentType: toPtr(featureFlagContentType),
},
},
pageETags: map[Selector][]*azcore.ETag{},
pageETags: map[selectorKey][]*azcore.ETag{},
}

mockClient.On("getSettings", ctx).Return(mockResponse, nil)
Expand Down Expand Up @@ -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[selectorKey][]*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: "*",
TagFilter: []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[selectorKey][]*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: "*",
TagFilter: []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",
TagFilter: []string{"env=production", "team=backend"},
}

selector2 := Selector{
KeyFilter: "app*",
LabelFilter: "prod",
TagFilter: []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.TagFilter)
assert.Equal(t, `["env=production","team=backend"]`, key2.TagFilter)
}

func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) {
// Test that selectors handle special characters in tag values correctly
selector := Selector{
KeyFilter: "app*",
LabelFilter: "prod",
TagFilter: []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.TagFilter)
}

func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) {
// Test empty TagFilter
selector1 := Selector{
KeyFilter: "app*",
LabelFilter: "prod",
TagFilter: []string{},
}

key1 := selector1.comparableKey()
assert.Equal(t, "[]", key1.TagFilter)

// Test nil TagFilter (should be handled the same as empty)
selector2 := Selector{
KeyFilter: "app*",
LabelFilter: "prod",
TagFilter: nil,
}

key2 := selector2.comparableKey()
assert.Equal(t, "[]", key2.TagFilter)
}

func TestSelectorComparableKey_Deterministic(t *testing.T) {
// Test that the same selector always produces the same key
selector := Selector{
KeyFilter: "app*",
LabelFilter: "prod",
TagFilter: []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.TagFilter) // Should be sorted
}
2 changes: 1 addition & 1 deletion azureappconfiguration/client_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion azureappconfiguration/failover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
2 changes: 1 addition & 1 deletion azureappconfiguration/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions azureappconfiguration/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
4 changes: 2 additions & 2 deletions azureappconfiguration/internal/tracing/tracing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 36 additions & 1 deletion azureappconfiguration/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -79,6 +81,39 @@ 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

// TagFilter 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.
TagFilter []string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know why we use TagFilters in provider but TagsFilter in SDK ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember there's a thread of discussion, let me find it for you

}

// 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() selectorKey {
// Create a copy and sort the TagFilter to ensure deterministic comparison
tagFilter := make([]string, len(s.TagFilter))
copy(tagFilter, s.TagFilter)
sort.Strings(tagFilter)

// Use JSON encoding for robust serialization that handles all special characters
tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail

return selectorKey{
KeyFilter: s.KeyFilter,
LabelFilter: s.LabelFilter,
SnapshotName: s.SnapshotName,
TagFilter: string(tagFilterJSON),
}
}

// selectorKey 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 selectorKey struct {
KeyFilter string
LabelFilter string
SnapshotName string
TagFilter string // Sorted, JSON-encoded representation of the original TagFilter slice
}

// KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh
Expand Down
Loading
Loading