Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
34 changes: 28 additions & 6 deletions azureappconfiguration/azureappconfiguration.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,25 @@ import (

// An AzureAppConfiguration is a configuration provider that stores and manages settings sourced from Azure App Configuration.
type AzureAppConfiguration struct {
// Settings loaded from Azure App Configuration
keyValues map[string]any

// Settings configured from Options
kvSelectors []Selector
trimPrefixes []string
watchedSettings []WatchedSetting

Copy link
Member

@RichardChen820 RichardChen820 May 15, 2025

Choose a reason for hiding this comment

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

There are blank lines between some fields, which seem to be used to categorize the fields, but I didn't get the underlying rule, it's likely just random to me for now.

Copy link
Member Author

Choose a reason for hiding this comment

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

At the top, keyValues and feastureFlags(in the future) are settings from store. In the middle, options configured by user. Next, cache for refresh scenario. clientManager and resolver are objects talk to service.

Copy link
Member

@RichardChen820 RichardChen820 May 15, 2025

Choose a reason for hiding this comment

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

How about adding some comments for them?

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated.

// Settings used for refresh scenarios
sentinelETags map[WatchedSetting]*azcore.ETag
watchAll bool
pageETags map[Selector][]*azcore.ETag
keyVaultRefs map[string]string // unversioned Key Vault references
kvRefreshTimer refresh.Condition
secretRefreshTimer refresh.Condition
onRefreshSuccess []func()
tracingOptions tracing.Options

// Clients talking to Azure App Configuration/Azure Key Vault service
clientManager *configurationClientManager
resolver *keyVaultReferenceResolver

Expand Down Expand Up @@ -99,6 +105,10 @@ 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.pageETags = make(map[Selector][]*azcore.ETag)
if len(options.RefreshOptions.WatchedSettings) == 0 {
azappcfg.watchAll = true
}
}

if options.KeyVaultOptions.RefreshOptions.Enabled {
Expand Down Expand Up @@ -355,6 +365,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin
maps.Copy(kvSettings, secrets)
azappcfg.keyValues = kvSettings
azappcfg.keyVaultRefs = getUnversionedKeyVaultRefs(keyVaultRefs)
azappcfg.pageETags = settingsResponse.pageETags

return nil
}
Expand Down Expand Up @@ -407,7 +418,7 @@ func (azappcfg *AzureAppConfiguration) refreshKeyValues(ctx context.Context, ref
// Check if any ETags have changed
eTagChanged, err := refreshClient.monitor.checkIfETagChanged(ctx)
if err != nil {
return false, fmt.Errorf("failed to check if watched settings have changed: %w", err)
return false, fmt.Errorf("failed to check if key value settings have changed: %w", err)
Copy link
Member

@RichardChen820 RichardChen820 May 15, 2025

Choose a reason for hiding this comment

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

feels that the previous error message is just fine, "watched settings" is also accurate for "watch all" scenario.

Copy link
Member Author

Choose a reason for hiding this comment

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

We have WatchedSettings refer to sentinel keys, using watched settings in watchAll scenarios is a little ambiguous. Besides, we also have feature flag refresh in the future, "key values" here can differentiate these scenarios.

}

if !eTagChanged {
Expand Down Expand Up @@ -572,17 +583,28 @@ func normalizedWatchedSettings(s []WatchedSetting) []WatchedSetting {
}

func (azappcfg *AzureAppConfiguration) newKeyValueRefreshClient() refreshClient {
return refreshClient{
loader: &selectorSettingsClient{
selectors: azappcfg.kvSelectors,
var monitor eTagsClient
if azappcfg.watchAll {
monitor = &pageETagsClient{
client: azappcfg.clientManager.staticClient.client,
tracingOptions: azappcfg.tracingOptions,
pageETags: azappcfg.pageETags,
}
} else {
monitor = &watchedSettingClient{
client: azappcfg.clientManager.staticClient.client,
tracingOptions: azappcfg.tracingOptions,
},
monitor: &watchedSettingClient{
eTags: azappcfg.sentinelETags,
}
}

return refreshClient{
loader: &selectorSettingsClient{
selectors: azappcfg.kvSelectors,
client: azappcfg.clientManager.staticClient.client,
tracingOptions: azappcfg.tracingOptions,
},
monitor: monitor,
sentinels: &watchedSettingClient{
watchedSettings: azappcfg.watchedSettings,
client: azappcfg.clientManager.staticClient.client,
Expand Down
12 changes: 6 additions & 6 deletions 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.23.0

require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.1.0
require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0

require (
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect
Expand All @@ -13,12 +13,12 @@ require (
)

require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1
github.com/go-viper/mapstructure/v2 v2.2.1
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0
golang.org/x/text v0.23.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0
golang.org/x/text v0.24.0 // indirect
)
36 changes: 18 additions & 18 deletions azureappconfiguration/go.sum
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=
github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.1.0 h1:AdaGDU3FgoUC2tsd3vsd9JblRrpFLUsS38yh1eLYfwM=
github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.1.0/go.mod h1:6tpINME7dnF7bLlb8Ubj6FtM9CFZrCn7aT02pcYrklM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 h1:Bg8m3nq/X1DeePkAbCfb6ml6F3F0IunEhE8TMh+lY48=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4=
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/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1 h1:mrkDCdkMsD4l9wjFGhofFHFrV43Y3c53RSLKOCJ5+Ow=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1/go.mod h1:hPv41DbqMmnxcGralanA/kVlfdH5jv3T4LxGku2E1BY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
Expand Down Expand Up @@ -36,16 +36,16 @@ 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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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=
Expand Down
3 changes: 2 additions & 1 deletion azureappconfiguration/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Options struct {
// Each selector combines a key filter and label filter
// If selectors are not provided, all key-values with no label are loaded by default.
Selectors []Selector

// RefreshOptions contains optional parameters to configure the behavior of key-value settings refresh
RefreshOptions KeyValueRefreshOptions

Expand All @@ -36,7 +37,7 @@ type Options struct {
// AuthenticationOptions contains parameters for authenticating with the Azure App Configuration service.
// Either a connection string or an endpoint with credential must be provided.
type AuthenticationOptions struct {
// Credential is a token credential for Azure EntraID Authenticaiton.
// Credential is a token credential for Azure EntraID Authentication.
// Required when Endpoint is provided.
Credential azcore.TokenCredential

Expand Down
111 changes: 96 additions & 15 deletions azureappconfiguration/refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -60,21 +61,6 @@ func TestRefresh_NotConfigured(t *testing.T) {
assert.Contains(t, err.Error(), "refresh is not enabled for either key values or Key Vault secrets")
}

func TestRefreshEnabled_EmptyWatchedSettings(t *testing.T) {
// Test verifying validation when refresh is enabled but no watched settings
options := &Options{
RefreshOptions: KeyValueRefreshOptions{
Enabled: true, // Enabled but without watched settings
WatchedSettings: []WatchedSetting{},
},
}

// Verify error
err := verifyOptions(options)
require.Error(t, err)
assert.Contains(t, err.Error(), "watched settings cannot be empty")
}

func TestRefreshEnabled_IntervalTooShort(t *testing.T) {
// Test verifying validation when refresh interval is too short
options := &Options{
Expand Down Expand Up @@ -563,3 +549,98 @@ func TestRefreshKeyVaultSecrets_WithMockResolver_Scenarios(t *testing.T) {
})
}
}

func TestRefresh_SettingsUpdated_WatchAll(t *testing.T) {
// Create initial cached values
initialKeyValues := map[string]any{
"setting1": "initial-value1",
"setting2": "initial-value2",
"setting3": "value-unchanged",
}

// Set up mock etags client that will detect changes
mockETags := &mockETagsClient{
changed: true, // Simulate that etags have changed
}

// Set up mock settings client that will return updated values
mockSettings := new(mockSettingsClient)
updatedValue1 := "updated-value1"
updatedValue2 := "new-value"
mockResponse := &settingsResponse{
settings: []azappconfig.Setting{
{Key: toPtr("setting1"), Value: &updatedValue1, ContentType: toPtr("")},
{Key: toPtr("setting3"), Value: toPtr("value-unchanged"), ContentType: toPtr("")},
{Key: toPtr("setting4"), Value: &updatedValue2, ContentType: toPtr("")}, // New setting
// Note: setting2 is missing - will be removed
},
}
mockSettings.On("getSettings", mock.Anything).Return(mockResponse, nil)

// Create refresh client wrapping the mocks
mockRefreshClient := refreshClient{
monitor: mockETags,
loader: mockSettings,
}

// Set up AzureAppConfiguration with initial values and refresh capabilities
azappcfg := &AzureAppConfiguration{
keyValues: make(map[string]any),
kvRefreshTimer: &mockRefreshCondition{shouldRefresh: true},
watchAll: true, // Enable watching all settings
}

// Copy initial values
for k, v := range initialKeyValues {
azappcfg.keyValues[k] = v
}

// Call Refresh
changed, err := azappcfg.refreshKeyValues(context.Background(), mockRefreshClient)

// Verify results
require.NoError(t, err)
assert.True(t, changed, "Expected cache to be updated")

// Verify cache was updated correctly
assert.Equal(t, "updated-value1", *azappcfg.keyValues["setting1"].(*string), "Setting1 should be updated")
assert.Equal(t, "value-unchanged", *azappcfg.keyValues["setting3"].(*string), "Setting3 should remain unchanged")
assert.Equal(t, "new-value", *azappcfg.keyValues["setting4"].(*string), "Setting4 should be added")

// Verify setting2 was removed
_, exists := azappcfg.keyValues["setting2"]
assert.False(t, exists, "Setting2 should be removed")

// Verify mocks were called as expected
mockSettings.AssertExpectations(t)
assert.Equal(t, 1, mockETags.checkCallCount, "ETag check should be called once")
}

// TestRefreshKeyValues_NoChanges tests when no ETags change is detected
func TestRefreshKeyValues_NoChanges_WatchAll(t *testing.T) {
// Setup mocks
mockTimer := &mockRefreshCondition{shouldRefresh: true}
mockMonitor := &mockETagsClient{changed: false}
mockLoader := &mockKvRefreshClient{}

mockClient := refreshClient{
loader: mockLoader,
monitor: mockMonitor,
}

// Setup provider
azappcfg := &AzureAppConfiguration{
kvRefreshTimer: mockTimer,
watchAll: true,
}

// Call refreshKeyValues
refreshed, err := azappcfg.refreshKeyValues(context.Background(), mockClient)

// Verify results
assert.NoError(t, err)
assert.False(t, refreshed, "Should return false when no changes detected")
assert.Equal(t, 1, mockMonitor.checkCallCount, "Monitor should be called exactly once")
assert.Equal(t, 0, mockLoader.getCallCount, "Loader should not be called when no changes")
assert.True(t, mockTimer.resetCalled, "Timer should be reset even when no changes")
}
Loading
Loading