diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index 82cdc03..6837414 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -28,6 +28,7 @@ import ( "sync/atomic" "time" + "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/afd" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/jsonc" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/refresh" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" @@ -55,8 +56,8 @@ type AzureAppConfiguration struct { // Settings used for refresh scenarios sentinelETags map[WatchedSetting]*azcore.ETag watchAll bool - kvETags map[comparableSelector][]*azcore.ETag - ffETags map[comparableSelector][]*azcore.ETag + kvWatchers map[comparableSelector][]settingWatcher + ffWatchers map[comparableSelector][]settingWatcher keyVaultRefs map[string]string // unversioned Key Vault references kvRefreshTimer refresh.Condition secretRefreshTimer refresh.Condition @@ -117,11 +118,17 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op credential: options.KeyVaultOptions.Credential, } + if authentication.Credential != nil { + if _, ok := authentication.Credential.(*afd.EmptyTokenCredential); ok { + azappcfg.tracingOptions.AfdUsed = true + } + } + if options.RefreshOptions.Enabled { azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) - azappcfg.kvETags = make(map[comparableSelector][]*azcore.ETag) + azappcfg.kvWatchers = make(map[comparableSelector][]settingWatcher) if len(options.RefreshOptions.WatchedSettings) == 0 { azappcfg.watchAll = true } @@ -137,7 +144,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[comparableSelector][]*azcore.ETag) + azappcfg.ffWatchers = make(map[comparableSelector][]settingWatcher) } } @@ -150,6 +157,41 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op return azappcfg, nil } +func (azappcfg *AzureAppConfiguration) LoadFromAzureFrontDoor(ctx context.Context, endpoint string, options *Options) (*AzureAppConfiguration, error) { + if options == nil { + options = &Options{} + } + + if options.ReplicaDiscoveryEnabled != nil && *options.ReplicaDiscoveryEnabled { + return nil, errors.New("replica discovery is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd") + } + + if options.LoadBalancingEnabled { + return nil, errors.New("load balancing is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd") + } + + if options.RefreshOptions.Enabled && + len(options.RefreshOptions.WatchedSettings) > 0 { + return nil, errors.New("specifying watched settings is not supported when loading from Azure Front Door. If refresh is enabled, all loaded configuration settings will be watched automatically") + } + + disableReplicaDiscovery := false + options.ReplicaDiscoveryEnabled = &disableReplicaDiscovery + + if options.ClientOptions == nil { + options.ClientOptions = &azappconfig.ClientOptions{} + } + + options.ClientOptions.PerRetryPolicies = append(options.ClientOptions.PerRetryPolicies, afd.NewAnonymousRequestPipelinePolicy(), afd.NewRemoveSyncTokenPipelinePolicy()) + + authOptions := AuthenticationOptions{ + Endpoint: endpoint, + Credential: afd.NewEmptyTokenCredential(), + } + + return Load(ctx, authOptions, options) +} + // Unmarshal parses the configuration and stores the result in the value pointed to v. It builds a hierarchical configuration structure based on key separators. // It supports converting values to appropriate target types. // @@ -432,7 +474,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin maps.Copy(kvSettings, secrets) azappcfg.keyValues = kvSettings azappcfg.keyVaultRefs = getUnversionedKeyVaultRefs(keyVaultRefs) - azappcfg.kvETags = settingsResponse.pageETags + azappcfg.kvWatchers = settingsResponse.pageWatchers return nil } @@ -509,7 +551,7 @@ func (azappcfg *AzureAppConfiguration) loadFeatureFlags(ctx context.Context, set }, } - azappcfg.ffETags = settingsResponse.pageETags + azappcfg.ffWatchers = settingsResponse.pageWatchers azappcfg.featureFlags = ffSettings return nil @@ -857,10 +899,10 @@ func normalizedWatchedSettings(s []WatchedSetting) []WatchedSetting { func (azappcfg *AzureAppConfiguration) newKeyValueRefreshClient(client *azappconfig.Client) refreshClient { var monitor eTagsClient if azappcfg.watchAll { - monitor = &pageETagsClient{ + monitor = &pageWatcherClient{ client: client, tracingOptions: azappcfg.tracingOptions, - pageETags: azappcfg.kvETags, + pageWatchers: azappcfg.kvWatchers, } } else { monitor = &watchedSettingClient{ @@ -892,10 +934,10 @@ func (azappcfg *AzureAppConfiguration) newFeatureFlagRefreshClient(client *azapp client: client, tracingOptions: azappcfg.tracingOptions, }, - monitor: &pageETagsClient{ + monitor: &pageWatcherClient{ client: client, tracingOptions: azappcfg.tracingOptions, - pageETags: azappcfg.ffETags, + pageWatchers: azappcfg.ffWatchers, }, } } diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 681b622..b6ee329 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -15,7 +15,6 @@ 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/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -128,7 +127,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[comparableSelector][]*azcore.ETag{}, + pageWatchers: map[comparableSelector][]settingWatcher{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1545,7 +1544,7 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { ContentType: toPtr(featureFlagContentType), }, }, - pageETags: map[comparableSelector][]*azcore.ETag{}, + pageWatchers: map[comparableSelector][]settingWatcher{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1639,7 +1638,7 @@ func TestLoadKeyValues_WithTagFilter(t *testing.T) { }, }, }, - pageETags: map[comparableSelector][]*azcore.ETag{}, + pageWatchers: map[comparableSelector][]settingWatcher{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1695,7 +1694,7 @@ func TestLoadKeyValues_WithMultipleTagFilters(t *testing.T) { }, }, }, - pageETags: map[comparableSelector][]*azcore.ETag{}, + pageWatchers: map[comparableSelector][]settingWatcher{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) diff --git a/azureappconfiguration/internal/afd/empty_token_cred.go b/azureappconfiguration/internal/afd/empty_token_cred.go new file mode 100644 index 0000000..e599839 --- /dev/null +++ b/azureappconfiguration/internal/afd/empty_token_cred.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package afd + +import ( + "context" + "math" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +type EmptyTokenCredential struct{} + +func (e *EmptyTokenCredential) GetToken(ctx context.Context, options policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{ + Token: "", + ExpiresOn: time.Unix(math.MaxInt64, 0), + }, nil +} + +func NewEmptyTokenCredential() azcore.TokenCredential { + return &EmptyTokenCredential{} +} diff --git a/azureappconfiguration/internal/afd/policy.go b/azureappconfiguration/internal/afd/policy.go new file mode 100644 index 0000000..ed7019e --- /dev/null +++ b/azureappconfiguration/internal/afd/policy.go @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package afd + +import ( + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +type AnonymousRequestPipelinePolicy struct{} + +type RemoveSyncTokenPipelinePolicy struct{} + +func NewAnonymousRequestPipelinePolicy() *AnonymousRequestPipelinePolicy { + return &AnonymousRequestPipelinePolicy{} +} + +func NewRemoveSyncTokenPipelinePolicy() *RemoveSyncTokenPipelinePolicy { + return &RemoveSyncTokenPipelinePolicy{} +} + +func (p *AnonymousRequestPipelinePolicy) Do(req *policy.Request) (*http.Response, error) { + if req.Raw().Header.Get("Authorization") != "" { + req.Raw().Header.Del("Authorization") + } + + return req.Next() +} + +func (p *RemoveSyncTokenPipelinePolicy) Do(req *policy.Request) (*http.Response, error) { + if req.Raw().Header.Get("Sync-Token") != "" { + req.Raw().Header.Del("Sync-Token") + } + + return req.Next() +} diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index 2c30ccf..830d6ac 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -43,6 +43,7 @@ const ( FailoverRequestTag = "Failover" ReplicaCountKey = "ReplicaCount" LoadBalancingEnabledTag = "LB" + AFDUsedTag = "AFD" // Feature flag usage tracing FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" @@ -77,6 +78,7 @@ type Options struct { IsFailoverRequest bool ReplicaCount int IsLoadBalancingEnabled bool + AfdUsed bool FeatureFlagTracing *FeatureFlagTracing FMVersion string } @@ -143,6 +145,10 @@ func CreateCorrelationContextHeader(ctx context.Context, options Options) http.H features = append(features, LoadBalancingEnabledTag) } + if options.AfdUsed { + features = append(features, AFDUsedTag) + } + if len(features) > 0 { featureStr := FeaturesKey + "=" + strings.Join(features, DelimiterPlus) output = append(output, featureStr) diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 937a72c..3f7859a 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -91,7 +91,7 @@ type Selector struct { // 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{ + cs := comparableSelector{ KeyFilter: s.KeyFilter, LabelFilter: s.LabelFilter, SnapshotName: s.SnapshotName, diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index d269322..f0747c8 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -9,6 +9,8 @@ import ( "errors" "fmt" "log" + "net/http" + "time" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -17,10 +19,15 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) +type settingWatcher struct { + eTag *azcore.ETag + lastServerResponseTime string +} + type settingsResponse struct { settings []azappconfig.Setting watchedETags map[WatchedSetting]*azcore.ETag - pageETags map[comparableSelector][]*azcore.ETag + pageWatchers map[comparableSelector][]settingWatcher } type selectorSettingsClient struct { @@ -36,8 +43,8 @@ type watchedSettingClient struct { tracingOptions tracing.Options } -type pageETagsClient struct { - pageETags map[comparableSelector][]*azcore.ETag +type pageWatcherClient struct { + pageWatchers map[comparableSelector][]settingWatcher client *azappconfig.Client tracingOptions tracing.Options } @@ -61,8 +68,11 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, s.tracingOptions)) } + // Capture the raw HTTP response + var httpResponse *http.Response + ctx = policy.WithCaptureResponse(ctx, &httpResponse) settings := make([]azappconfig.Setting, 0) - pageETags := make(map[comparableSelector][]*azcore.ETag) + pageWatchers := make(map[comparableSelector][]settingWatcher) for _, filter := range s.selectors { if filter.SnapshotName == "" { selector := azappconfig.SettingSelector{ @@ -73,18 +83,24 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } pager := s.client.NewListSettingsPager(selector, nil) - eTags := make([]*azcore.ETag, 0) + watchers := make([]settingWatcher, 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) + watchers = append(watchers, settingWatcher{ + eTag: page.ETag, + }) + + if s.tracingOptions.AfdUsed && httpResponse != nil { + watchers[len(watchers)-1].lastServerResponseTime = httpResponse.Header.Get("X-Ms-Date") + } } } - pageETags[filter.comparableKey()] = eTags + pageWatchers[filter.comparableKey()] = watchers } else { snapshot, err := s.client.GetSnapshot(ctx, filter.SnapshotName, nil) if err != nil { @@ -108,8 +124,8 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } return &settingsResponse{ - settings: settings, - pageETags: pageETags, + settings: settings, + pageWatchers: pageWatchers, }, nil } @@ -168,8 +184,12 @@ func (c *watchedSettingClient) checkIfETagChanged(ctx context.Context) (bool, er return false, nil } -func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) { - for selector, pageETags := range c.pageETags { +func (c *pageWatcherClient) checkIfETagChanged(ctx context.Context) (bool, error) { + // Capture the raw HTTP response + var httpResponse *http.Response + ctx = policy.WithCaptureResponse(ctx, &httpResponse) + + for selector, pageWatchers := range c.pageWatchers { s := azappconfig.SettingSelector{ KeyFilter: to.Ptr(selector.KeyFilter), LabelFilter: to.Ptr(selector.LabelFilter), @@ -183,28 +203,41 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) } conditions := make([]azcore.MatchConditions, 0) - for _, eTag := range pageETags { - conditions = append(conditions, azcore.MatchConditions{IfNoneMatch: eTag}) + for _, watcher := range pageWatchers { + conditions = append(conditions, azcore.MatchConditions{IfNoneMatch: watcher.eTag}) } - pager := c.client.NewListSettingsPager(s, &azappconfig.ListSettingsOptions{ - MatchConditions: conditions, - }) + listOps := &azappconfig.ListSettingsOptions{} + if !c.tracingOptions.AfdUsed { + listOps.MatchConditions = conditions + } + + pager := c.client.NewListSettingsPager(s, listOps) pageCount := 0 for pager.More() { pageCount++ - page, err := pager.NextPage(context.Background()) + page, err := pager.NextPage(ctx) if err != nil { return false, err } // ETag changed if page.ETag != nil { - return true, nil + if !c.tracingOptions.AfdUsed { + return true, nil + } + + if httpResponse != nil { + serverResponseTime, _ := time.Parse(time.RFC1123, httpResponse.Header.Get("X-Ms-Date")) + lastResponseTime, _ := time.Parse(time.RFC1123, pageWatchers[pageCount-1].lastServerResponseTime) + if lastResponseTime.Before(serverResponseTime) { + return true, nil + } + } } } - if pageCount != len(pageETags) { + if pageCount != len(pageWatchers) { return true, nil } }