diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index 84f3ed0..713d9c4 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -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 + // 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 @@ -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 { @@ -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 } @@ -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) } if !eTagChanged { @@ -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, diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index 702cfb8..9b3e86c 100644 --- a/azureappconfiguration/go.mod +++ b/azureappconfiguration/go.mod @@ -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 @@ -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 ) diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index ab81167..e25e595 100644 --- a/azureappconfiguration/go.sum +++ b/azureappconfiguration/go.sum @@ -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= @@ -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= diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 9716786..750f537 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -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 @@ -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 diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index d8a3ef4..e4bdb1d 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -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" ) @@ -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{ @@ -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") +} diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index 212177b..e10235a 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -18,6 +18,7 @@ import ( type settingsResponse struct { settings []azappconfig.Setting watchedETags map[WatchedSetting]*azcore.ETag + pageETags map[Selector][]*azcore.ETag } type selectorSettingsClient struct { @@ -33,6 +34,12 @@ type watchedSettingClient struct { tracingOptions tracing.Options } +type pageETagsClient struct { + pageETags map[Selector][]*azcore.ETag + client *azappconfig.Client + tracingOptions tracing.Options +} + type settingsClient interface { getSettings(ctx context.Context) (*settingsResponse, error) } @@ -53,6 +60,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } settings := make([]azappconfig.Setting, 0) + pageETags := make(map[Selector][]*azcore.ETag) for _, filter := range s.selectors { selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), @@ -61,18 +69,23 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } pager := s.client.NewListSettingsPager(selector, nil) + eTags := make([]*azcore.ETag, 0) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, err } else if page.Settings != nil { settings = append(settings, page.Settings...) + eTags = append(eTags, page.ETag) } } + + pageETags[filter] = eTags } return &settingsResponse{ - settings: settings, + settings: settings, + pageETags: pageETags, }, nil } @@ -130,3 +143,45 @@ func (c *watchedSettingClient) checkIfETagChanged(ctx context.Context) (bool, er return false, nil } + +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), + LabelFilter: to.Ptr(selector.LabelFilter), + Fields: azappconfig.AllSettingFields(), + } + + conditions := make([]azcore.MatchConditions, 0) + for _, eTag := range pageETags { + conditions = append(conditions, azcore.MatchConditions{IfNoneMatch: eTag}) + } + + pager := c.client.NewListSettingsPager(s, &azappconfig.ListSettingsOptions{ + MatchConditions: conditions, + }) + + pageCount := 0 + for pager.More() { + pageCount++ + page, err := pager.NextPage(context.Background()) + if err != nil { + return false, err + } + // ETag changed + if page.ETag != nil { + return true, nil + } + } + + if pageCount != len(pageETags) { + return true, nil + } + } + + return false, nil +} diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 6bb473a..104b9fb 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -35,10 +35,6 @@ func verifyOptions(options *Options) error { return fmt.Errorf("key value refresh interval cannot be less than %s", minimalRefreshInterval) } - if len(options.RefreshOptions.WatchedSettings) == 0 { - return fmt.Errorf("watched settings cannot be empty") - } - for _, watchedSetting := range options.RefreshOptions.WatchedSettings { if watchedSetting.Key == "" { return fmt.Errorf("watched setting key cannot be empty")